@dynatrace-oss/dynatrace-mcp-server 0.6.0-rc.1 → 0.6.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 +53 -67
- package/dist/capabilities/davis-copilot.js +36 -35
- package/dist/capabilities/execute-dql.js +29 -5
- package/dist/capabilities/execute-dql.test.js +113 -0
- package/dist/capabilities/send-email.js +62 -0
- package/dist/getDynatraceEnv.js +6 -1
- package/dist/getDynatraceEnv.test.js +1 -0
- package/dist/index.js +173 -32
- package/dist/utils/grail-budget-tracker.js +139 -0
- package/dist/utils/grail-budget-tracker.test.js +182 -0
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -18,14 +18,30 @@
|
|
|
18
18
|
</a>
|
|
19
19
|
</h4>
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
The local _Dynatrace MCP server_ allows AI Assistants to interact with the [Dynatrace](https://www.dynatrace.com/) observability platform,
|
|
22
|
+
bringing real-time observability data directly into your development workflow.
|
|
23
23
|
|
|
24
24
|
> Note: This product is not officially supported by Dynatrace.
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
If you need help, please contact us via [GitHub Issues](https://github.com/dynatrace-oss/dynatrace-mcp/issues) if you have feature requests, questions, or need help.
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
## Quickstart
|
|
29
|
+
|
|
30
|
+
You can add this MCP server to your MCP Client like VSCode, Claude, Cursor, Amazon Q, Windsurf, ChatGPT, or Github Copilot via the npmjs package `@dynatrace-oss/dynatrace-mcp-server`, and type `stdio`.
|
|
31
|
+
You can find more details about the configuration for different AI Assistants, Agents and MCP Clients in the [Configuration section below](#configuration).
|
|
32
|
+
|
|
33
|
+
Furthermore, you need your Dynatrace environment URL, e.g., `https://abc12345.apps.dynatrace.com`, as well as a [Platform Token](https://docs.dynatrace.com/docs/manage/identity-access-management/access-tokens-and-oauth-clients/platform-tokens), e.g., `dt0s16.SAMPLE.abcd1234`, with [required scopes](#scopes-for-authentication).
|
|
34
|
+
|
|
35
|
+
Depending on your MCP Client, you need to configure these as environment variables or as settings in the UI:
|
|
36
|
+
|
|
37
|
+
- `DT_ENVIRONMENT` (string, e.g., `https://abc12345.apps.dynatrace.com`) - URL to your Dynatrace Platform (do not use Dynatrace classic URLs like `abc12345.live.dynatrace.com`)
|
|
38
|
+
- `DT_PLATFORM_TOKEN` (string, e.g., `dt0s16.SAMPLE.abcd1234`) - **Recommended**: Dynatrace Platform Token
|
|
39
|
+
|
|
40
|
+
Once you are done, we recommend looking into [example prompts](#-example-prompts-), like `Get all details of the entity 'my-service'` or `Show me error logs`. Please mind that these prompts lead to executing DQL statements which may incur [costs](#costs) in accordance to your licence.
|
|
41
|
+
|
|
42
|
+
## Architecture
|
|
43
|
+
|
|
44
|
+

|
|
29
45
|
|
|
30
46
|
## Use cases
|
|
31
47
|
|
|
@@ -60,6 +76,17 @@ depend on the volume (GB scanned).
|
|
|
60
76
|
1. Review your current Dynatrace consumption model and pricing
|
|
61
77
|
2. Understand the cost implications of the specific data you plan to query (logs, events, metrics) - see [Dynatrace Pricing and Rate Card](https://www.dynatrace.com/pricing/)
|
|
62
78
|
3. Start with smaller timeframes (e.g., 12h-24h) and make use of [buckets](https://docs.dynatrace.com/docs/discover-dynatrace/platform/grail/data-model#built-in-grail-buckets) to reduce the cost impact
|
|
79
|
+
4. Set an appropriate `DT_GRAIL_QUERY_BUDGET_GB` environment variable (default: 1000 GB) to control and monitor your Grail query consumption
|
|
80
|
+
|
|
81
|
+
**Grail Budget Tracking:**
|
|
82
|
+
|
|
83
|
+
The MCP server includes built-in budget tracking for Grail queries to help you monitor and control costs:
|
|
84
|
+
|
|
85
|
+
- Set `DT_GRAIL_QUERY_BUDGET_GB` (default: 1000 GB) to define your session budget limit
|
|
86
|
+
- The server tracks bytes scanned across all Grail queries in the current session
|
|
87
|
+
- You'll receive warnings when approaching 80% of your budget
|
|
88
|
+
- Budget exceeded alerts help prevent unexpected high consumption
|
|
89
|
+
- Budget resets when you restart the MCP server session
|
|
63
90
|
|
|
64
91
|
**To understand costs that occured:**
|
|
65
92
|
|
|
@@ -173,7 +200,7 @@ rules/
|
|
|
173
200
|
|
|
174
201
|
For detailed information about the workshop rules, see the [Rules README](./dynatrace-agent-rules/rules/README.md).
|
|
175
202
|
|
|
176
|
-
##
|
|
203
|
+
## Configuration
|
|
177
204
|
|
|
178
205
|
You can add this MCP server (using STDIO) to your MCP Client like VS Code, Claude, Cursor, Amazon Q Developer CLI, Windsurf Github Copilot via the package `@dynatrace-oss/dynatrace-mcp-server`.
|
|
179
206
|
|
|
@@ -288,19 +315,6 @@ npx -y @dynatrace-oss/dynatrace-mcp-server@latest --version
|
|
|
288
315
|
}
|
|
289
316
|
```
|
|
290
317
|
|
|
291
|
-
**Configuration for MCP clients that support HTTP transport:**
|
|
292
|
-
|
|
293
|
-
```json
|
|
294
|
-
{
|
|
295
|
-
"mcpServers": {
|
|
296
|
-
"dynatrace-http": {
|
|
297
|
-
"url": "http://localhost:3000",
|
|
298
|
-
"transport": "http"
|
|
299
|
-
}
|
|
300
|
-
}
|
|
301
|
-
}
|
|
302
|
-
```
|
|
303
|
-
|
|
304
318
|
### Rule File
|
|
305
319
|
|
|
306
320
|
For efficient result retrieval from Dynatrace, please consider creating a rule file (e.g., [.github/copilot-instructions.md](https://docs.github.com/en/copilot/how-tos/configure-custom-instructions/add-repository-instructions), [.amazonq/rules/](https://docs.aws.amazon.com/amazonq/latest/qdeveloper-ug/context-project-rules.html)), instructing coding agents on how to get more details for your component/app/service. Here is an example for [easytrade](https://github.com/Dynatrace/easytrade), please adapt the names and filters to fit your use-cases and components:
|
|
@@ -330,10 +344,11 @@ For fetching just error-logs, add `| filter loglevel == "ERROR"`.
|
|
|
330
344
|
|
|
331
345
|
You can set up authentication via **Platform Tokens** (recommended) or **OAuth Client** via the following environment variables:
|
|
332
346
|
|
|
333
|
-
- `DT_ENVIRONMENT` (string, e.g., https://abc12345.apps.dynatrace.com) - URL to your Dynatrace Platform (do not use Dynatrace classic URLs like `abc12345.live.dynatrace.com`)
|
|
347
|
+
- `DT_ENVIRONMENT` (string, e.g., `https://abc12345.apps.dynatrace.com`) - URL to your Dynatrace Platform (do not use Dynatrace classic URLs like `abc12345.live.dynatrace.com`)
|
|
334
348
|
- `DT_PLATFORM_TOKEN` (string, e.g., `dt0s16.SAMPLE.abcd1234`) - **Recommended**: Dynatrace Platform Token
|
|
335
349
|
- `OAUTH_CLIENT_ID` (string, e.g., `dt0s02.SAMPLE`) - Alternative: Dynatrace OAuth Client ID (for advanced use cases)
|
|
336
350
|
- `OAUTH_CLIENT_SECRET` (string, e.g., `dt0s02.SAMPLE.abcd1234`) - Alternative: Dynatrace OAuth Client Secret (for advanced use cases)
|
|
351
|
+
- `DT_GRAIL_QUERY_BUDGET_GB` (number, default: `1000`) - Budget limit in GB (base 1000) for Grail query bytes scanned per session. The MCP server tracks your Grail usage and warns when approaching or exceeding this limit.
|
|
337
352
|
|
|
338
353
|
**Platform Tokens are recommended** for most use cases as they provide a simpler authentication flow. OAuth Clients should only be used when specific OAuth features are required.
|
|
339
354
|
|
|
@@ -371,6 +386,7 @@ Depending on the features you are using, the following scopes are needed:
|
|
|
371
386
|
- `davis-copilot:conversations:execute` - execute conversational skill (chat with Copilot)
|
|
372
387
|
- `davis-copilot:nl2dql:execute` - execute Davis Copilot Natural Language (NL) to DQL skill
|
|
373
388
|
- `davis-copilot:dql2nl:execute` - execute DQL to Natural Language (NL) skill
|
|
389
|
+
- `email:emails:send` - needed for `send_email` tool to send emails
|
|
374
390
|
- `settings:objects:read` - needed for reading ownership information and Guardians (SRG) from settings
|
|
375
391
|
|
|
376
392
|
**Note**: Please ensure that `settings:objects:read` is used, and _not_ the similarly named scope `app-settings:objects:read`.
|
|
@@ -384,6 +400,18 @@ and extend them as needed. They're here to help you imagine how real-time observ
|
|
|
384
400
|
|
|
385
401
|
### **Basic Queries & AI Assistance**
|
|
386
402
|
|
|
403
|
+
**Find a monitored entity**
|
|
404
|
+
|
|
405
|
+
```
|
|
406
|
+
Get all details of the entity 'my-service'
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
**Find error logs**
|
|
410
|
+
|
|
411
|
+
```
|
|
412
|
+
Show me error logs
|
|
413
|
+
```
|
|
414
|
+
|
|
387
415
|
**Write a DQL query from natural language:**
|
|
388
416
|
|
|
389
417
|
```
|
|
@@ -403,6 +431,12 @@ fetch logs | filter dt.source_entity == 'SERVICE-123' | summarize count(), by:{s
|
|
|
403
431
|
How can I investigate slow database queries in Dynatrace?
|
|
404
432
|
```
|
|
405
433
|
|
|
434
|
+
**Send email notifications:**
|
|
435
|
+
|
|
436
|
+
```
|
|
437
|
+
Send an email notification about the incident to the responsible team at team@example.com with CC to manager@example.com
|
|
438
|
+
```
|
|
439
|
+
|
|
406
440
|
### **Advanced Incident Investigation**
|
|
407
441
|
|
|
408
442
|
**Multi-phase incident response:**
|
|
@@ -602,51 +636,3 @@ To disable usage tracking, add this to your environment:
|
|
|
602
636
|
```bash
|
|
603
637
|
DT_MCP_DISABLE_TELEMETRY=true
|
|
604
638
|
```
|
|
605
|
-
|
|
606
|
-
## Development
|
|
607
|
-
|
|
608
|
-
For local development purposes, you can use VSCode and GitHub Copilot.
|
|
609
|
-
|
|
610
|
-
First, enable Copilot for your Workspace `.vscode/settings.json`:
|
|
611
|
-
|
|
612
|
-
```json
|
|
613
|
-
{
|
|
614
|
-
"github.copilot.enable": {
|
|
615
|
-
"*": true
|
|
616
|
-
}
|
|
617
|
-
}
|
|
618
|
-
```
|
|
619
|
-
|
|
620
|
-
and make sure that you are using Agent Mode in Copilot.
|
|
621
|
-
|
|
622
|
-
Second, add the MCP to `.vscode/mcp.json`:
|
|
623
|
-
|
|
624
|
-
```json
|
|
625
|
-
{
|
|
626
|
-
"servers": {
|
|
627
|
-
"my-dynatrace-mcp-server": {
|
|
628
|
-
"command": "node",
|
|
629
|
-
"args": ["--watch", "${workspaceFolder}/dist/index.js"],
|
|
630
|
-
"envFile": "${workspaceFolder}/.env"
|
|
631
|
-
}
|
|
632
|
-
}
|
|
633
|
-
}
|
|
634
|
-
```
|
|
635
|
-
|
|
636
|
-
Third, create a `.env` file in this repository (you can copy from `.env.template`) and configure environment variables as [described above](#environment-variables).
|
|
637
|
-
|
|
638
|
-
Finally, make changes to your code and compile it with `npm run build` or just run `npm run watch` and it auto-compiles.
|
|
639
|
-
|
|
640
|
-
## Releasing
|
|
641
|
-
|
|
642
|
-
When you are preparing for a release, you can use GitHub Copilot to guide you through the preparations.
|
|
643
|
-
|
|
644
|
-
In Visual Studio Code, you can use `/release` in the chat with Copilot in Agent Mode, which will execute [release.prompt.md](.github/prompts/release.prompt.md).
|
|
645
|
-
|
|
646
|
-
You may include additional information such as the version number. If not specified, you will be asked.
|
|
647
|
-
|
|
648
|
-
This will
|
|
649
|
-
|
|
650
|
-
- prepare the [changelog](CHANGELOG.md),
|
|
651
|
-
- update the version number in [package.json](package.json),
|
|
652
|
-
- commit the changes.
|
|
@@ -1,7 +1,23 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Davis CoPilot API Integration
|
|
4
|
+
*
|
|
5
|
+
* This module provides access to Davis CoPilot AI capabilities including:
|
|
6
|
+
* - Natural Language to DQL conversion
|
|
7
|
+
* - DQL explanation in plain English
|
|
8
|
+
* - AI-powered conversation assistance
|
|
9
|
+
* - Feedback submission for continuous improvement
|
|
10
|
+
*
|
|
11
|
+
* Note: While Davis CoPilot AI is generally available (GA),
|
|
12
|
+
* the Davis CoPilot APIs are currently in preview.
|
|
13
|
+
* For more information: https://dt-url.net/copilot-community
|
|
14
|
+
*
|
|
15
|
+
* DQL (Dynatrace Query Language) is the most powerful way to query any data
|
|
16
|
+
* in Dynatrace, including problem events, security issues, logs, metrics, and spans.
|
|
17
|
+
*/
|
|
2
18
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
19
|
exports.chatWithDavisCopilot = exports.explainDqlInNaturalLanguage = exports.generateDqlFromNaturalLanguage = void 0;
|
|
4
|
-
|
|
20
|
+
const client_davis_copilot_1 = require("@dynatrace-sdk/client-davis-copilot");
|
|
5
21
|
/**
|
|
6
22
|
* Generate DQL from natural language
|
|
7
23
|
* Converts plain English descriptions into powerful Dynatrace Query Language (DQL) statements.
|
|
@@ -9,17 +25,10 @@ exports.chatWithDavisCopilot = exports.explainDqlInNaturalLanguage = exports.gen
|
|
|
9
25
|
* security issues, logs, metrics, spans, and custom data.
|
|
10
26
|
*/
|
|
11
27
|
const generateDqlFromNaturalLanguage = async (dtClient, text) => {
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
url: '/platform/davis/copilot/v0.2/skills/nl2dql:generate',
|
|
16
|
-
headers: {
|
|
17
|
-
'Content-Type': 'application/json',
|
|
18
|
-
'Accept': 'application/json',
|
|
19
|
-
},
|
|
20
|
-
body: JSON.stringify(request),
|
|
28
|
+
const client = new client_davis_copilot_1.PublicClient(dtClient);
|
|
29
|
+
return await client.nl2dql({
|
|
30
|
+
body: { text },
|
|
21
31
|
});
|
|
22
|
-
return await response.body('json');
|
|
23
32
|
};
|
|
24
33
|
exports.generateDqlFromNaturalLanguage = generateDqlFromNaturalLanguage;
|
|
25
34
|
/**
|
|
@@ -29,35 +38,27 @@ exports.generateDqlFromNaturalLanguage = generateDqlFromNaturalLanguage;
|
|
|
29
38
|
* queries for problem events, security issues, and performance metrics.
|
|
30
39
|
*/
|
|
31
40
|
const explainDqlInNaturalLanguage = async (dtClient, dql) => {
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
url: '/platform/davis/copilot/v0.2/skills/dql2nl:explain',
|
|
36
|
-
headers: {
|
|
37
|
-
'Content-Type': 'application/json',
|
|
38
|
-
'Accept': 'application/json',
|
|
39
|
-
},
|
|
40
|
-
body: request, // Not sure why this does not need JSON.stringify, but it only works like this; once we have the SDK, this will be consistent
|
|
41
|
+
const client = new client_davis_copilot_1.PublicClient(dtClient);
|
|
42
|
+
return await client.dql2nl({
|
|
43
|
+
body: { dql },
|
|
41
44
|
});
|
|
42
|
-
return await response.body('json');
|
|
43
45
|
};
|
|
44
46
|
exports.explainDqlInNaturalLanguage = explainDqlInNaturalLanguage;
|
|
45
47
|
const chatWithDavisCopilot = async (dtClient, text, context, annotations, state) => {
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
method: 'POST',
|
|
54
|
-
url: '/platform/davis/copilot/v0.2/skills/conversations:message',
|
|
55
|
-
headers: {
|
|
56
|
-
'Content-Type': 'application/json',
|
|
57
|
-
'Accept': 'application/json',
|
|
48
|
+
const client = new client_davis_copilot_1.PublicClient(dtClient);
|
|
49
|
+
const response = await client.recommenderConversation({
|
|
50
|
+
body: {
|
|
51
|
+
text,
|
|
52
|
+
context,
|
|
53
|
+
annotations,
|
|
54
|
+
state,
|
|
58
55
|
},
|
|
59
|
-
body: JSON.stringify(request),
|
|
60
56
|
});
|
|
61
|
-
|
|
57
|
+
// Type guard: RecommenderResponse is ConversationResponse | EventArray
|
|
58
|
+
// In practice, the SDK defaults to non-streaming and returns ConversationResponse
|
|
59
|
+
if (Array.isArray(response)) {
|
|
60
|
+
throw new Error('Unexpected streaming response format. Please raise an issue at https://github.com/dynatrace-oss/dynatrace-mcp/issues.');
|
|
61
|
+
}
|
|
62
|
+
return response;
|
|
62
63
|
};
|
|
63
64
|
exports.chatWithDavisCopilot = chatWithDavisCopilot;
|
|
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.executeDql = exports.verifyDqlStatement = void 0;
|
|
4
4
|
const client_query_1 = require("@dynatrace-sdk/client-query");
|
|
5
5
|
const user_agent_1 = require("../utils/user-agent");
|
|
6
|
+
const grail_budget_tracker_1 = require("../utils/grail-budget-tracker");
|
|
6
7
|
const verifyDqlStatement = async (dtClient, dqlStatement) => {
|
|
7
8
|
const queryAssistanceClient = new client_query_1.QueryAssistanceClient(dtClient);
|
|
8
9
|
const response = await queryAssistanceClient.queryVerify({
|
|
@@ -17,17 +18,29 @@ exports.verifyDqlStatement = verifyDqlStatement;
|
|
|
17
18
|
* Helper function to create a DQL execution result and log metadata information.
|
|
18
19
|
* @param queryResult - The query result from Dynatrace API
|
|
19
20
|
* @param logPrefix - Prefix for the log message (e.g., "DQL Execution Metadata" or "DQL Execution Metadata (Polled)")
|
|
21
|
+
* @param budgetLimitGB - Budget limit in GB for tracking purposes
|
|
20
22
|
* @returns DqlExecutionResult with extracted metadata
|
|
21
23
|
*/
|
|
22
|
-
const createResultAndLog = (queryResult, logPrefix) => {
|
|
24
|
+
const createResultAndLog = (queryResult, logPrefix, budgetLimitGB) => {
|
|
25
|
+
const scannedBytes = queryResult.metadata?.grail?.scannedBytes || 0;
|
|
26
|
+
// Track budget if limit is provided
|
|
27
|
+
let budgetState;
|
|
28
|
+
let budgetWarning;
|
|
29
|
+
if (budgetLimitGB !== undefined) {
|
|
30
|
+
const tracker = (0, grail_budget_tracker_1.getGrailBudgetTracker)(budgetLimitGB);
|
|
31
|
+
budgetState = tracker.addBytesScanned(scannedBytes);
|
|
32
|
+
budgetWarning = (0, grail_budget_tracker_1.generateBudgetWarning)(budgetState, scannedBytes) || undefined;
|
|
33
|
+
}
|
|
23
34
|
const result = {
|
|
24
35
|
records: queryResult.records,
|
|
25
36
|
metadata: queryResult.metadata,
|
|
26
|
-
scannedBytes
|
|
37
|
+
scannedBytes,
|
|
27
38
|
scannedRecords: queryResult.metadata?.grail?.scannedRecords,
|
|
28
39
|
executionTimeMilliseconds: queryResult.metadata?.grail?.executionTimeMilliseconds,
|
|
29
40
|
queryId: queryResult.metadata?.grail?.queryId,
|
|
30
41
|
sampled: queryResult.metadata?.grail?.sampled,
|
|
42
|
+
budgetState,
|
|
43
|
+
budgetWarning,
|
|
31
44
|
};
|
|
32
45
|
console.error(`${logPrefix} scannedBytes=${result.scannedBytes} scannedRecords=${result.scannedRecords} executionTime=${result.executionTimeMilliseconds} queryId=${result.queryId}`);
|
|
33
46
|
return result;
|
|
@@ -38,9 +51,20 @@ const createResultAndLog = (queryResult, logPrefix) => {
|
|
|
38
51
|
* If the result is not immediately available, it will poll for the result until it is available.
|
|
39
52
|
* @param dtClient
|
|
40
53
|
* @param body - Contains the DQL statement to execute, and optional parameters like maxResultRecords and maxResultBytes
|
|
54
|
+
* @param budgetLimitGB - Optional budget limit in GB for tracking bytes scanned
|
|
41
55
|
* @returns the result with records, metadata and cost information, or undefined if the query failed or no result was returned.
|
|
42
56
|
*/
|
|
43
|
-
const executeDql = async (dtClient, body) => {
|
|
57
|
+
const executeDql = async (dtClient, body, budgetLimitGB) => {
|
|
58
|
+
// Check budget before executing the query if budget limit is provided
|
|
59
|
+
if (budgetLimitGB !== undefined) {
|
|
60
|
+
const tracker = (0, grail_budget_tracker_1.getGrailBudgetTracker)(budgetLimitGB);
|
|
61
|
+
const currentState = tracker.getState();
|
|
62
|
+
if (currentState.isBudgetExceeded) {
|
|
63
|
+
console.error('DQL execution aborted: Grail budget has been exceeded');
|
|
64
|
+
const budgetWarning = (0, grail_budget_tracker_1.generateBudgetWarning)(currentState, 0);
|
|
65
|
+
throw new Error(budgetWarning || 'DQL execution aborted: Grail budget has been exceeded');
|
|
66
|
+
}
|
|
67
|
+
}
|
|
44
68
|
// create a Dynatrace QueryExecutionClient
|
|
45
69
|
const queryExecutionClient = new client_query_1.QueryExecutionClient(dtClient);
|
|
46
70
|
// and execute the query (part of body)
|
|
@@ -52,7 +76,7 @@ const executeDql = async (dtClient, body) => {
|
|
|
52
76
|
// check if we already got a result back
|
|
53
77
|
if (response.result) {
|
|
54
78
|
// yes - return response result immediately
|
|
55
|
-
return createResultAndLog(response.result, 'execute_dql - Metadata:');
|
|
79
|
+
return createResultAndLog(response.result, 'execute_dql - Metadata:', budgetLimitGB);
|
|
56
80
|
}
|
|
57
81
|
// no result yet? we have wait and poll (this requires requestToken to be set)
|
|
58
82
|
if (response.requestToken) {
|
|
@@ -68,7 +92,7 @@ const executeDql = async (dtClient, body) => {
|
|
|
68
92
|
// check if we got a result from the polling endpoint
|
|
69
93
|
if (pollResponse.result) {
|
|
70
94
|
// yes - let's return the polled result
|
|
71
|
-
return createResultAndLog(pollResponse.result, 'execute_dql Metadata (polled):');
|
|
95
|
+
return createResultAndLog(pollResponse.result, 'execute_dql Metadata (polled):', budgetLimitGB);
|
|
72
96
|
}
|
|
73
97
|
} while (pollResponse.state === 'RUNNING' || pollResponse.state === 'NOT_STARTED');
|
|
74
98
|
// state != RUNNING and != NOT_STARTED - we should log that
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const execute_dql_1 = require("./execute-dql");
|
|
4
|
+
const client_query_1 = require("@dynatrace-sdk/client-query");
|
|
5
|
+
const grail_budget_tracker_1 = require("../utils/grail-budget-tracker");
|
|
6
|
+
// Mock the external dependencies
|
|
7
|
+
jest.mock('@dynatrace-sdk/client-query');
|
|
8
|
+
jest.mock('../utils/user-agent', () => ({
|
|
9
|
+
getUserAgent: () => 'test-user-agent',
|
|
10
|
+
}));
|
|
11
|
+
describe('executeDql Budget Check', () => {
|
|
12
|
+
let mockHttpClient;
|
|
13
|
+
let mockQueryExecutionClient;
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
// Reset budget tracker before each test
|
|
16
|
+
(0, grail_budget_tracker_1.resetGrailBudgetTracker)();
|
|
17
|
+
// Create mock HTTP client
|
|
18
|
+
mockHttpClient = {
|
|
19
|
+
// Add any necessary properties/methods for HttpClient mock
|
|
20
|
+
};
|
|
21
|
+
// Create mock QueryExecutionClient
|
|
22
|
+
mockQueryExecutionClient = {
|
|
23
|
+
queryExecute: jest.fn(),
|
|
24
|
+
queryPoll: jest.fn(),
|
|
25
|
+
queryCancel: jest.fn(),
|
|
26
|
+
};
|
|
27
|
+
// Mock the QueryExecutionClient constructor
|
|
28
|
+
client_query_1.QueryExecutionClient.mockImplementation(() => mockQueryExecutionClient);
|
|
29
|
+
});
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
jest.clearAllMocks();
|
|
32
|
+
(0, grail_budget_tracker_1.resetGrailBudgetTracker)();
|
|
33
|
+
});
|
|
34
|
+
it('should prevent execution when budget is exceeded', async () => {
|
|
35
|
+
const budgetLimitGB = 0.001; // Very small budget limit (1 MB)
|
|
36
|
+
// First, exhaust the budget by adding bytes to tracker
|
|
37
|
+
const tracker = (0, grail_budget_tracker_1.getGrailBudgetTracker)(budgetLimitGB);
|
|
38
|
+
tracker.addBytesScanned(2 * 1000 * 1000); // Add 2 MB, exceeding the 1 MB limit
|
|
39
|
+
const dqlStatement = 'fetch logs | limit 10';
|
|
40
|
+
const body = { query: dqlStatement };
|
|
41
|
+
// Execute DQL with budget limit and expect it to throw
|
|
42
|
+
await expect((0, execute_dql_1.executeDql)(mockHttpClient, body, budgetLimitGB)).rejects.toThrow(/budget/);
|
|
43
|
+
// Verify that queryExecute was NOT called
|
|
44
|
+
expect(mockQueryExecutionClient.queryExecute).not.toHaveBeenCalled();
|
|
45
|
+
});
|
|
46
|
+
it('should allow execution when budget is not exceeded', async () => {
|
|
47
|
+
const budgetLimitGB = 1; // 1 GB budget limit
|
|
48
|
+
const dqlStatement = 'fetch logs | limit 10';
|
|
49
|
+
const body = { query: dqlStatement };
|
|
50
|
+
// Mock successful response
|
|
51
|
+
const mockResponse = {
|
|
52
|
+
state: 'RUNNING',
|
|
53
|
+
result: {
|
|
54
|
+
records: [{ field1: 'value1' }],
|
|
55
|
+
types: [],
|
|
56
|
+
metadata: {
|
|
57
|
+
grail: {
|
|
58
|
+
scannedBytes: 1000,
|
|
59
|
+
scannedRecords: 1,
|
|
60
|
+
executionTimeMilliseconds: 100,
|
|
61
|
+
queryId: 'test-query-id',
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
mockQueryExecutionClient.queryExecute.mockResolvedValue(mockResponse);
|
|
67
|
+
// Execute DQL with budget limit
|
|
68
|
+
const result = await (0, execute_dql_1.executeDql)(mockHttpClient, body, budgetLimitGB);
|
|
69
|
+
// Verify that queryExecute WAS called
|
|
70
|
+
expect(mockQueryExecutionClient.queryExecute).toHaveBeenCalledWith({
|
|
71
|
+
body,
|
|
72
|
+
dtClientContext: 'test-user-agent',
|
|
73
|
+
});
|
|
74
|
+
// Verify the result is returned correctly
|
|
75
|
+
expect(result).toBeDefined();
|
|
76
|
+
expect(result?.records).toEqual([{ field1: 'value1' }]);
|
|
77
|
+
expect(result?.scannedBytes).toBe(1000);
|
|
78
|
+
expect(result?.budgetState?.isBudgetExceeded).toBe(false);
|
|
79
|
+
});
|
|
80
|
+
it('should allow execution when no budget limit is provided', async () => {
|
|
81
|
+
const dqlStatement = 'fetch logs | limit 10';
|
|
82
|
+
const body = { query: dqlStatement };
|
|
83
|
+
// Mock successful response
|
|
84
|
+
const mockResponse = {
|
|
85
|
+
state: 'RUNNING',
|
|
86
|
+
result: {
|
|
87
|
+
records: [{ field1: 'value1' }],
|
|
88
|
+
types: [],
|
|
89
|
+
metadata: {
|
|
90
|
+
grail: {
|
|
91
|
+
scannedBytes: 1000000000, // 1 GB - would exceed small budgets
|
|
92
|
+
scannedRecords: 1000,
|
|
93
|
+
executionTimeMilliseconds: 100,
|
|
94
|
+
queryId: 'test-query-id',
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
mockQueryExecutionClient.queryExecute.mockResolvedValue(mockResponse);
|
|
100
|
+
// Execute DQL without budget limit
|
|
101
|
+
const result = await (0, execute_dql_1.executeDql)(mockHttpClient, body);
|
|
102
|
+
// Verify that queryExecute WAS called
|
|
103
|
+
expect(mockQueryExecutionClient.queryExecute).toHaveBeenCalledWith({
|
|
104
|
+
body,
|
|
105
|
+
dtClientContext: 'test-user-agent',
|
|
106
|
+
});
|
|
107
|
+
// Verify the result is returned correctly
|
|
108
|
+
expect(result).toBeDefined();
|
|
109
|
+
expect(result?.records).toEqual([{ field1: 'value1' }]);
|
|
110
|
+
expect(result?.scannedBytes).toBe(1000000000);
|
|
111
|
+
expect(result?.budgetState).toBeUndefined(); // No budget tracking
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.sendEmail = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* Send an email using the Dynatrace Email API
|
|
6
|
+
* @param dtClient - Dynatrace HTTP client
|
|
7
|
+
* @param emailRequest - Email request parameters
|
|
8
|
+
* @returns Structured email response with request ID and status
|
|
9
|
+
*/
|
|
10
|
+
const sendEmail = async (dtClient, emailRequest) => {
|
|
11
|
+
// Validate total recipients limit (10 max across TO, CC, and BCC)
|
|
12
|
+
const totalRecipients = emailRequest.toRecipients.emailAddresses.length +
|
|
13
|
+
(emailRequest.ccRecipients?.emailAddresses.length || 0) +
|
|
14
|
+
(emailRequest.bccRecipients?.emailAddresses.length || 0);
|
|
15
|
+
if (totalRecipients > 10) {
|
|
16
|
+
throw new Error(`Total recipients (${totalRecipients}) exceeds maximum limit of 10 across TO, CC, and BCC fields`);
|
|
17
|
+
}
|
|
18
|
+
try {
|
|
19
|
+
// Ensure contentType is set to 'text/plain' (our only supported format)
|
|
20
|
+
const requestBody = {
|
|
21
|
+
...emailRequest,
|
|
22
|
+
body: {
|
|
23
|
+
...emailRequest.body,
|
|
24
|
+
contentType: 'text/plain',
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
const response = await dtClient.send({
|
|
28
|
+
url: '/platform/email/v1/emails',
|
|
29
|
+
method: 'POST',
|
|
30
|
+
headers: {
|
|
31
|
+
'Accept': 'application/json',
|
|
32
|
+
'Content-Type': 'application/json;charset=UTF-8',
|
|
33
|
+
},
|
|
34
|
+
body: requestBody,
|
|
35
|
+
statusValidator: (status) => {
|
|
36
|
+
return status === 202; // Email API returns 202 for successful requests
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
const result = await response.body('json');
|
|
40
|
+
const sendResult = {
|
|
41
|
+
success: true,
|
|
42
|
+
requestId: result.requestId,
|
|
43
|
+
message: result.message,
|
|
44
|
+
};
|
|
45
|
+
if (result.invalidDestinations && result.invalidDestinations.length > 0) {
|
|
46
|
+
sendResult.invalidDestinations = result.invalidDestinations;
|
|
47
|
+
}
|
|
48
|
+
if (result.rejectedDestinations) {
|
|
49
|
+
if (result.rejectedDestinations.bouncingDestinations.length > 0) {
|
|
50
|
+
sendResult.bouncingDestinations = result.rejectedDestinations.bouncingDestinations;
|
|
51
|
+
}
|
|
52
|
+
if (result.rejectedDestinations.complainingDestinations.length > 0) {
|
|
53
|
+
sendResult.complainingDestinations = result.rejectedDestinations.complainingDestinations;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return sendResult;
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
throw new Error(`Error sending email: ${error.message}`);
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
exports.sendEmail = sendEmail;
|
package/dist/getDynatraceEnv.js
CHANGED
|
@@ -11,17 +11,22 @@ function getDynatraceEnv(env = process.env) {
|
|
|
11
11
|
const dtPlatformToken = env.DT_PLATFORM_TOKEN;
|
|
12
12
|
const dtEnvironment = env.DT_ENVIRONMENT;
|
|
13
13
|
const slackConnectionId = env.SLACK_CONNECTION_ID || 'fake-slack-connection-id';
|
|
14
|
+
const grailBudgetGB = parseFloat(env.DT_GRAIL_QUERY_BUDGET_GB || '1000'); // Default to 1000 GB
|
|
14
15
|
if (!dtEnvironment) {
|
|
15
16
|
throw new Error('Please set DT_ENVIRONMENT environment variable to your Dynatrace Platform Environment');
|
|
16
17
|
}
|
|
17
18
|
if (!oauthClientId && !oauthClientSecret && !dtPlatformToken) {
|
|
18
19
|
throw new Error('Please set either OAUTH_CLIENT_ID and OAUTH_CLIENT_SECRET, or DT_PLATFORM_TOKEN environment variables');
|
|
19
20
|
}
|
|
21
|
+
// ToDo: Allow the case of -1 for unlimited Budget
|
|
22
|
+
if (isNaN(grailBudgetGB) || (grailBudgetGB <= 0 && grailBudgetGB !== -1)) {
|
|
23
|
+
throw new Error('DT_GRAIL_QUERY_BUDGET_GB must be a positive number representing GB budget for Grail queries');
|
|
24
|
+
}
|
|
20
25
|
if (!dtEnvironment.startsWith('https://')) {
|
|
21
26
|
throw new Error('Please set DT_ENVIRONMENT to a valid Dynatrace Environment URL (e.g., https://<environment-id>.apps.dynatrace.com)');
|
|
22
27
|
}
|
|
23
28
|
if (!dtEnvironment.includes('apps.dynatrace.com') && !dtEnvironment.includes('apps.dynatracelabs.com')) {
|
|
24
29
|
throw new Error('Please set DT_ENVIRONMENT to a valid Dynatrace Platform Environment URL (e.g., https://<environment-id>.apps.dynatrace.com)');
|
|
25
30
|
}
|
|
26
|
-
return { oauthClientId, oauthClientSecret, dtPlatformToken, dtEnvironment, slackConnectionId };
|
|
31
|
+
return { oauthClientId, oauthClientSecret, dtPlatformToken, dtEnvironment, slackConnectionId, grailBudgetGB };
|
|
27
32
|
}
|
|
@@ -18,6 +18,7 @@ describe('getDynatraceEnv', () => {
|
|
|
18
18
|
dtEnvironment: env.DT_ENVIRONMENT,
|
|
19
19
|
dtPlatformToken: env.DT_PLATFORM_TOKEN,
|
|
20
20
|
slackConnectionId: env.SLACK_CONNECTION_ID,
|
|
21
|
+
grailBudgetGB: 1000, // Default value
|
|
21
22
|
});
|
|
22
23
|
});
|
|
23
24
|
it('uses default slackConnectionId if not set', () => {
|
package/dist/index.js
CHANGED
|
@@ -8,7 +8,6 @@ const streamableHttp_js_1 = require("@modelcontextprotocol/sdk/server/streamable
|
|
|
8
8
|
const mcp_js_1 = require("@modelcontextprotocol/sdk/server/mcp.js");
|
|
9
9
|
const dotenv_1 = require("dotenv");
|
|
10
10
|
const node_http_1 = require("node:http");
|
|
11
|
-
const node_crypto_1 = require("node:crypto");
|
|
12
11
|
const commander_1 = require("commander");
|
|
13
12
|
const zod_1 = require("zod");
|
|
14
13
|
const version_1 = require("./utils/version");
|
|
@@ -22,10 +21,12 @@ const create_workflow_for_problem_notification_1 = require("./capabilities/creat
|
|
|
22
21
|
const update_workflow_1 = require("./capabilities/update-workflow");
|
|
23
22
|
const execute_dql_1 = require("./capabilities/execute-dql");
|
|
24
23
|
const send_slack_message_1 = require("./capabilities/send-slack-message");
|
|
24
|
+
const send_email_1 = require("./capabilities/send-email");
|
|
25
25
|
const find_monitored_entity_by_name_1 = require("./capabilities/find-monitored-entity-by-name");
|
|
26
26
|
const davis_copilot_1 = require("./capabilities/davis-copilot");
|
|
27
27
|
const getDynatraceEnv_1 = require("./getDynatraceEnv");
|
|
28
28
|
const telemetry_openkit_1 = require("./utils/telemetry-openkit");
|
|
29
|
+
const grail_budget_tracker_1 = require("./utils/grail-budget-tracker");
|
|
29
30
|
(0, dotenv_1.config)();
|
|
30
31
|
let scopesBase = [
|
|
31
32
|
'app-engine:apps:run', // needed for environmentInformationClient
|
|
@@ -49,21 +50,13 @@ function handleClientRequestError(error) {
|
|
|
49
50
|
}
|
|
50
51
|
return `Client Request Error: ${error.message} with HTTP status: ${error.response.status}. ${additionalErrorInformation} (body: ${JSON.stringify(error.body)})`;
|
|
51
52
|
}
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
dynatraceEnv = (0, getDynatraceEnv_1.getDynatraceEnv)();
|
|
57
|
-
}
|
|
58
|
-
catch (err) {
|
|
59
|
-
console.error(err.message);
|
|
60
|
-
process.exit(1);
|
|
61
|
-
}
|
|
62
|
-
console.error(`Initializing Dynatrace MCP Server v${(0, version_1.getPackageJsonVersion)()}...`);
|
|
63
|
-
const { oauthClientId, oauthClientSecret, dtEnvironment, dtPlatformToken, slackConnectionId } = dynatraceEnv;
|
|
64
|
-
// Test connection on startup
|
|
53
|
+
/**
|
|
54
|
+
* Try to connect to Dynatrace environment with retries and exponential backoff.
|
|
55
|
+
*/
|
|
56
|
+
async function retryTestDynatraceConnection(dtEnvironment, oauthClientId, oauthClientSecret, dtPlatformToken) {
|
|
65
57
|
let retryCount = 0;
|
|
66
|
-
const maxRetries =
|
|
58
|
+
const maxRetries = 3; // Max retries
|
|
59
|
+
const delayMs = 2000; // Initial delay of 2 seconds
|
|
67
60
|
while (true) {
|
|
68
61
|
try {
|
|
69
62
|
console.error(`Testing connection to Dynatrace environment: ${dtEnvironment}... (Attempt ${retryCount + 1} of ${maxRetries})`);
|
|
@@ -82,13 +75,36 @@ const main = async () => {
|
|
|
82
75
|
retryCount++;
|
|
83
76
|
if (retryCount >= maxRetries) {
|
|
84
77
|
console.error(`Fatal: Maximum number of connection retries (${maxRetries}) exceeded. Exiting.`);
|
|
85
|
-
|
|
78
|
+
throw new Error(`Failed to connect to Dynatrace environment ${dtEnvironment} after ${maxRetries} attempts. Most likely your configuration is incorrect. Last error: ${error.message}`, { cause: error });
|
|
86
79
|
}
|
|
87
|
-
const delay = Math.pow(2, retryCount) *
|
|
80
|
+
const delay = Math.pow(2, retryCount) * delayMs; // Exponential backoff
|
|
88
81
|
console.error(`Retrying in ${delay / 1000} seconds...`);
|
|
89
82
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
90
83
|
}
|
|
91
84
|
}
|
|
85
|
+
}
|
|
86
|
+
const main = async () => {
|
|
87
|
+
console.error(`Initializing Dynatrace MCP Server v${(0, version_1.getPackageJsonVersion)()}...`);
|
|
88
|
+
// read Environment variables
|
|
89
|
+
let dynatraceEnv;
|
|
90
|
+
try {
|
|
91
|
+
dynatraceEnv = (0, getDynatraceEnv_1.getDynatraceEnv)();
|
|
92
|
+
}
|
|
93
|
+
catch (err) {
|
|
94
|
+
console.error(err.message);
|
|
95
|
+
process.exit(1);
|
|
96
|
+
}
|
|
97
|
+
// Unpack environment variables
|
|
98
|
+
const { oauthClientId, oauthClientSecret, dtEnvironment, dtPlatformToken, slackConnectionId, grailBudgetGB } = dynatraceEnv;
|
|
99
|
+
// Test connection on startup
|
|
100
|
+
try {
|
|
101
|
+
await retryTestDynatraceConnection(dtEnvironment, oauthClientId, oauthClientSecret, dtPlatformToken);
|
|
102
|
+
}
|
|
103
|
+
catch (err) {
|
|
104
|
+
console.error(err.message);
|
|
105
|
+
process.exit(2);
|
|
106
|
+
}
|
|
107
|
+
// Ready to start the server
|
|
92
108
|
console.error(`Starting Dynatrace MCP Server v${(0, version_1.getPackageJsonVersion)()}...`);
|
|
93
109
|
// Initialize usage tracking
|
|
94
110
|
const telemetry = (0, telemetry_openkit_1.createTelemetry)();
|
|
@@ -113,7 +129,7 @@ const main = async () => {
|
|
|
113
129
|
},
|
|
114
130
|
});
|
|
115
131
|
// quick abstraction/wrapper to make it easier for tools to reply text instead of JSON
|
|
116
|
-
const tool = (name, description, paramsSchema, cb) => {
|
|
132
|
+
const tool = (name, description, paramsSchema, annotations, cb) => {
|
|
117
133
|
const wrappedCb = async (args) => {
|
|
118
134
|
// track starttime for telemetry
|
|
119
135
|
const startTime = Date.now();
|
|
@@ -152,10 +168,12 @@ const main = async () => {
|
|
|
152
168
|
.catch((e) => console.warn('Failed to track tool usage:', e));
|
|
153
169
|
}
|
|
154
170
|
};
|
|
155
|
-
server.tool(name, description, paramsSchema, (args) => wrappedCb(args));
|
|
171
|
+
server.tool(name, description, paramsSchema, annotations, (args) => wrappedCb(args));
|
|
156
172
|
};
|
|
157
173
|
/** Tool Definitions below */
|
|
158
|
-
tool('get_environment_info', 'Get information about the connected Dynatrace Environment (Tenant) and verify the connection and authentication.', {},
|
|
174
|
+
tool('get_environment_info', 'Get information about the connected Dynatrace Environment (Tenant) and verify the connection and authentication.', {}, {
|
|
175
|
+
readOnlyHint: true,
|
|
176
|
+
}, async ({}) => {
|
|
159
177
|
// create an oauth-client
|
|
160
178
|
const dtClient = await (0, dynatrace_clients_1.createDtHttpClient)(dtEnvironment, scopesBase, oauthClientId, oauthClientSecret, dtPlatformToken);
|
|
161
179
|
const environmentInformationClient = new client_platform_management_service_1.EnvironmentInformationClient(dtClient);
|
|
@@ -179,6 +197,8 @@ const main = async () => {
|
|
|
179
197
|
.number()
|
|
180
198
|
.default(25)
|
|
181
199
|
.describe('Maximum number of vulnerabilities to display in the response.'),
|
|
200
|
+
}, {
|
|
201
|
+
readOnlyHint: true,
|
|
182
202
|
}, async ({ riskScore, additionalFilter, maxVulnerabilitiesToDisplay }) => {
|
|
183
203
|
const dtClient = await (0, dynatrace_clients_1.createDtHttpClient)(dtEnvironment, scopesBase.concat('storage:events:read', 'storage:buckets:read', 'storage:security.events:read'), oauthClientId, oauthClientSecret, dtPlatformToken);
|
|
184
204
|
const result = await (0, list_vulnerabilities_1.listVulnerabilities)(dtClient, additionalFilter, riskScore);
|
|
@@ -220,6 +240,8 @@ const main = async () => {
|
|
|
220
240
|
.optional()
|
|
221
241
|
.describe('Additional filter for DQL statement for dt.davis.problems, e.g., \'entity_tags == array("dt.owner:team-foobar", "tag:tag")\''),
|
|
222
242
|
maxProblemsToDisplay: zod_1.z.number().default(10).describe('Maximum number of problems to display in the response.'),
|
|
243
|
+
}, {
|
|
244
|
+
readOnlyHint: true,
|
|
223
245
|
}, async ({ additionalFilter, maxProblemsToDisplay }) => {
|
|
224
246
|
const dtClient = await (0, dynatrace_clients_1.createDtHttpClient)(dtEnvironment, scopesBase.concat('storage:events:read', 'storage:buckets:read'), oauthClientId, oauthClientSecret, dtPlatformToken);
|
|
225
247
|
// get problems (uses fetch)
|
|
@@ -254,6 +276,8 @@ const main = async () => {
|
|
|
254
276
|
});
|
|
255
277
|
tool('find_entity_by_name', 'Get the entityId of a monitored entity (service, host, process-group, application, kubernetes-node, ...) within the topology based on the name of the entity on Dynatrace', {
|
|
256
278
|
entityName: zod_1.z.string().describe('Name of the entity to search for, e.g., "my-service" or "my-host"'),
|
|
279
|
+
}, {
|
|
280
|
+
readOnlyHint: true,
|
|
257
281
|
}, async ({ entityName }) => {
|
|
258
282
|
const dtClient = await (0, dynatrace_clients_1.createDtHttpClient)(dtEnvironment, scopesBase.concat('storage:entities:read'), oauthClientId, oauthClientSecret, dtPlatformToken);
|
|
259
283
|
const entityResponse = await (0, find_monitored_entity_by_name_1.findMonitoredEntityByName)(dtClient, entityName);
|
|
@@ -261,6 +285,8 @@ const main = async () => {
|
|
|
261
285
|
});
|
|
262
286
|
tool('get_entity_details', 'Get details of a monitored entity based on the entityId on Dynatrace', {
|
|
263
287
|
entityId: zod_1.z.string().optional(),
|
|
288
|
+
}, {
|
|
289
|
+
readOnlyHint: true,
|
|
264
290
|
}, async ({ entityId }) => {
|
|
265
291
|
const dtClient = await (0, dynatrace_clients_1.createDtHttpClient)(dtEnvironment, scopesBase.concat('storage:entities:read'), oauthClientId, oauthClientSecret, dtPlatformToken);
|
|
266
292
|
const entityDetails = await (0, get_monitored_entity_details_1.getMonitoredEntityDetails)(dtClient, entityId);
|
|
@@ -298,6 +324,9 @@ const main = async () => {
|
|
|
298
324
|
tool('send_slack_message', 'Sends a Slack message to a dedicated Slack Channel via Slack Connector on Dynatrace', {
|
|
299
325
|
channel: zod_1.z.string().optional(),
|
|
300
326
|
message: zod_1.z.string().optional(),
|
|
327
|
+
}, {
|
|
328
|
+
// not read-only, not open-world, not destructive
|
|
329
|
+
readOnlyHint: false,
|
|
301
330
|
}, async ({ channel, message }) => {
|
|
302
331
|
const dtClient = await (0, dynatrace_clients_1.createDtHttpClient)(dtEnvironment, scopesBase.concat('app-settings:objects:read'), oauthClientId, oauthClientSecret, dtPlatformToken);
|
|
303
332
|
const response = await (0, send_slack_message_1.sendSlackMessage)(dtClient, slackConnectionId, channel, message);
|
|
@@ -305,6 +334,9 @@ const main = async () => {
|
|
|
305
334
|
});
|
|
306
335
|
tool('verify_dql', 'Verify a Dynatrace Query Language (DQL) statement on Dynatrace GRAIL before executing it. This step is recommended for DQL statements that have been dynamically created by non-expert tools. For statements coming from the `generate_dql_from_natural_language` tool as well as from documentation, this step can be omitted.', {
|
|
307
336
|
dqlStatement: zod_1.z.string(),
|
|
337
|
+
}, {
|
|
338
|
+
readOnlyHint: true,
|
|
339
|
+
idempotentHint: true, // same input always yields same output
|
|
308
340
|
}, async ({ dqlStatement }) => {
|
|
309
341
|
const dtClient = await (0, dynatrace_clients_1.createDtHttpClient)(dtEnvironment, scopesBase, oauthClientId, oauthClientSecret, dtPlatformToken);
|
|
310
342
|
const response = await (0, execute_dql_1.verifyDqlStatement)(dtClient, dqlStatement);
|
|
@@ -329,6 +361,12 @@ const main = async () => {
|
|
|
329
361
|
dqlStatement: zod_1.z
|
|
330
362
|
.string()
|
|
331
363
|
.describe('DQL Statement (Ex: "fetch [logs, spans, events] | filter <some-filter> | summarize count(), by:{some-fields}.", or for metrics: "timeseries { avg(<metric-name>), value.A = avg(<metric-name>, scalar: true) }")'),
|
|
364
|
+
}, {
|
|
365
|
+
// not readonly (DQL statements may modify things), not idempotent (may change over time)
|
|
366
|
+
readOnlyHint: false,
|
|
367
|
+
idempotentHint: false,
|
|
368
|
+
// while we are not strictly talking to the open world here, the response from execute DQL could interpreted as a web-search, which often is referred to open-world
|
|
369
|
+
openWorldHint: true,
|
|
332
370
|
}, async ({ dqlStatement }) => {
|
|
333
371
|
// Create a HTTP Client that has all storage:*:read scopes
|
|
334
372
|
const dtClient = await (0, dynatrace_clients_1.createDtHttpClient)(dtEnvironment, scopesBase.concat('storage:buckets:read', // Read all system data stored on Grail
|
|
@@ -342,11 +380,15 @@ const main = async () => {
|
|
|
342
380
|
'storage:user.events:read', // Read User events from Grail
|
|
343
381
|
'storage:user.sessions:read', // Read User sessions from Grail
|
|
344
382
|
'storage:security.events:read'), oauthClientId, oauthClientSecret, dtPlatformToken);
|
|
345
|
-
const response = await (0, execute_dql_1.executeDql)(dtClient, { query: dqlStatement });
|
|
383
|
+
const response = await (0, execute_dql_1.executeDql)(dtClient, { query: dqlStatement }, grailBudgetGB);
|
|
346
384
|
if (!response) {
|
|
347
385
|
return 'DQL execution failed or returned no result.';
|
|
348
386
|
}
|
|
349
387
|
let result = `📊 **DQL Query Results**\n\n`;
|
|
388
|
+
// Budget warning comes first if present
|
|
389
|
+
if (response.budgetWarning) {
|
|
390
|
+
result += `${response.budgetWarning}\n\n`;
|
|
391
|
+
}
|
|
350
392
|
// Cost and Performance Information
|
|
351
393
|
if (response.scannedRecords !== undefined) {
|
|
352
394
|
result += `- **Scanned Records:** ${response.scannedRecords.toLocaleString()}\n`;
|
|
@@ -354,18 +396,23 @@ const main = async () => {
|
|
|
354
396
|
if (response.scannedBytes !== undefined) {
|
|
355
397
|
const scannedGB = response.scannedBytes / (1000 * 1000 * 1000);
|
|
356
398
|
result += `- **Scanned Bytes:** ${scannedGB.toFixed(2)} GB`;
|
|
357
|
-
//
|
|
399
|
+
// Show budget status if available
|
|
400
|
+
if (response.budgetState) {
|
|
401
|
+
const usagePercentage = (response.budgetState.totalBytesScanned / response.budgetState.budgetLimitBytes) * 100;
|
|
402
|
+
result += ` (Session total: ${(response.budgetState.totalBytesScanned / (1000 * 1000 * 1000)).toFixed(2)} GB / ${response.budgetState.budgetLimitGB} GB budget, ${usagePercentage.toFixed(1)}% used)`;
|
|
403
|
+
}
|
|
404
|
+
result += '\n';
|
|
358
405
|
if (scannedGB > 500) {
|
|
359
|
-
result +=
|
|
406
|
+
result += ` ⚠️ **Very High Data Usage Warning:** This query scanned ${scannedGB.toFixed(1)} GB of data, which may impact your Dynatrace consumption. Please take measures to optimize your query, like limiting the timeframe or selecting a bucket.\n`;
|
|
360
407
|
}
|
|
361
408
|
else if (scannedGB > 50) {
|
|
362
|
-
result +=
|
|
409
|
+
result += ` ⚠️ **High Data Usage Warning:** This query scanned ${scannedGB.toFixed(2)} GB of data, which may impact your Dynatrace consumption.\n`;
|
|
363
410
|
}
|
|
364
411
|
else if (scannedGB > 5) {
|
|
365
|
-
result +=
|
|
412
|
+
result += ` 💡 **Moderate Data Usage:** This query scanned ${scannedGB.toFixed(2)} GB of data.\n`;
|
|
366
413
|
}
|
|
367
414
|
else if (response.scannedBytes === 0) {
|
|
368
|
-
result +=
|
|
415
|
+
result += ` 💡 **No Data consumed:** This query did not consume any data.\n`;
|
|
369
416
|
}
|
|
370
417
|
}
|
|
371
418
|
if (response.sampled !== undefined && response.sampled) {
|
|
@@ -379,6 +426,9 @@ const main = async () => {
|
|
|
379
426
|
text: zod_1.z
|
|
380
427
|
.string()
|
|
381
428
|
.describe('Natural language description of what you want to query. Be specific and include time ranges, entities, and metrics of interest.'),
|
|
429
|
+
}, {
|
|
430
|
+
readOnlyHint: true,
|
|
431
|
+
idempotentHint: true,
|
|
382
432
|
}, async ({ text }) => {
|
|
383
433
|
const dtClient = await (0, dynatrace_clients_1.createDtHttpClient)(dtEnvironment, scopesBase.concat('davis-copilot:nl2dql:execute'), oauthClientId, oauthClientSecret, dtPlatformToken);
|
|
384
434
|
const response = await (0, davis_copilot_1.generateDqlFromNaturalLanguage)(dtClient, text);
|
|
@@ -405,6 +455,9 @@ const main = async () => {
|
|
|
405
455
|
});
|
|
406
456
|
tool('explain_dql_in_natural_language', 'Explain Dynatrace Query Language (DQL) statements in natural language using Davis CoPilot AI.', {
|
|
407
457
|
dql: zod_1.z.string().describe('The DQL statement to explain'),
|
|
458
|
+
}, {
|
|
459
|
+
readOnlyHint: true,
|
|
460
|
+
idempotentHint: true,
|
|
408
461
|
}, async ({ dql }) => {
|
|
409
462
|
const dtClient = await (0, dynatrace_clients_1.createDtHttpClient)(dtEnvironment, scopesBase.concat('davis-copilot:dql2nl:execute'), oauthClientId, oauthClientSecret, dtPlatformToken);
|
|
410
463
|
const response = await (0, davis_copilot_1.explainDqlInNaturalLanguage)(dtClient, dql);
|
|
@@ -426,6 +479,10 @@ const main = async () => {
|
|
|
426
479
|
text: zod_1.z.string().describe('Your question or request for Davis CoPilot'),
|
|
427
480
|
context: zod_1.z.string().optional().describe('Optional context to provide additional information'),
|
|
428
481
|
instruction: zod_1.z.string().optional().describe('Optional instruction for how to format the response'),
|
|
482
|
+
}, {
|
|
483
|
+
readOnlyHint: true,
|
|
484
|
+
idempotentHint: true,
|
|
485
|
+
openWorldHint: true, // web-search like characteristics
|
|
429
486
|
}, async ({ text, context, instruction }) => {
|
|
430
487
|
const dtClient = await (0, dynatrace_clients_1.createDtHttpClient)(dtEnvironment, scopesBase.concat('davis-copilot:conversations:execute'), oauthClientId, oauthClientSecret, dtPlatformToken);
|
|
431
488
|
const conversationContext = [];
|
|
@@ -475,6 +532,10 @@ const main = async () => {
|
|
|
475
532
|
teamName: zod_1.z.string().optional(),
|
|
476
533
|
channel: zod_1.z.string().optional(),
|
|
477
534
|
isPrivate: zod_1.z.boolean().optional().default(false),
|
|
535
|
+
}, {
|
|
536
|
+
// not read only, not idempotent
|
|
537
|
+
readOnlyHint: false,
|
|
538
|
+
idempotentHint: false, // creating the same workflow multiple times is possible
|
|
478
539
|
}, async ({ problemType, teamName, channel, isPrivate }) => {
|
|
479
540
|
const dtClient = await (0, dynatrace_clients_1.createDtHttpClient)(dtEnvironment, scopesBase.concat('automation:workflows:write', 'automation:workflows:read', 'automation:workflows:run'), oauthClientId, oauthClientSecret, dtPlatformToken);
|
|
480
541
|
const response = await (0, create_workflow_for_problem_notification_1.createWorkflowForProblemNotification)(dtClient, teamName, channel, problemType, isPrivate);
|
|
@@ -492,6 +553,10 @@ const main = async () => {
|
|
|
492
553
|
});
|
|
493
554
|
tool('make_workflow_public', 'Modify a workflow and make it publicly available to everyone on the Dynatrace Environment', {
|
|
494
555
|
workflowId: zod_1.z.string().optional(),
|
|
556
|
+
}, {
|
|
557
|
+
// not read only, but idempotent
|
|
558
|
+
readOnlyHint: false,
|
|
559
|
+
idempotentHint: true, // making the same workflow public multiple times yields the same result
|
|
495
560
|
}, async ({ workflowId }) => {
|
|
496
561
|
const dtClient = await (0, dynatrace_clients_1.createDtHttpClient)(dtEnvironment, scopesBase.concat('automation:workflows:write', 'automation:workflows:read', 'automation:workflows:run'), oauthClientId, oauthClientSecret, dtPlatformToken);
|
|
497
562
|
const response = await (0, update_workflow_1.updateWorkflow)(dtClient, workflowId, {
|
|
@@ -504,6 +569,8 @@ const main = async () => {
|
|
|
504
569
|
.string()
|
|
505
570
|
.optional()
|
|
506
571
|
.describe(`The Kubernetes (K8s) Cluster Id, referred to as k8s.cluster.uid (this is NOT the Dynatrace environment)`),
|
|
572
|
+
}, {
|
|
573
|
+
readOnlyHint: true,
|
|
507
574
|
}, async ({ clusterId }) => {
|
|
508
575
|
const dtClient = await (0, dynatrace_clients_1.createDtHttpClient)(dtEnvironment, scopesBase.concat('storage:events:read'), oauthClientId, oauthClientSecret, dtPlatformToken);
|
|
509
576
|
const events = await (0, get_events_for_cluster_1.getEventsForCluster)(dtClient, clusterId);
|
|
@@ -511,6 +578,8 @@ const main = async () => {
|
|
|
511
578
|
});
|
|
512
579
|
tool('get_ownership', 'Get detailed Ownership information for one or multiple entities on Dynatrace', {
|
|
513
580
|
entityIds: zod_1.z.string().optional().describe('Comma separated list of entityIds'),
|
|
581
|
+
}, {
|
|
582
|
+
readOnlyHint: true,
|
|
514
583
|
}, async ({ entityIds }) => {
|
|
515
584
|
const dtClient = await (0, dynatrace_clients_1.createDtHttpClient)(dtEnvironment, scopesBase.concat('environment-api:entities:read', 'settings:objects:read'), oauthClientId, oauthClientSecret, dtPlatformToken);
|
|
516
585
|
console.error(`Fetching ownership for ${entityIds}`);
|
|
@@ -520,6 +589,71 @@ const main = async () => {
|
|
|
520
589
|
resp += JSON.stringify(ownershipInformation);
|
|
521
590
|
return resp;
|
|
522
591
|
});
|
|
592
|
+
tool('reset_grail_budget', 'Reset the Grail query budget after it was exhausted, allowing new queries to be executed. This clears all tracked bytes scanned in the current session.', {}, {
|
|
593
|
+
readonlyHint: false, // modifies state
|
|
594
|
+
idempotentHint: true, // multiple resets yield the same result
|
|
595
|
+
}, async ({}) => {
|
|
596
|
+
// Reset the global tracker
|
|
597
|
+
(0, grail_budget_tracker_1.resetGrailBudgetTracker)();
|
|
598
|
+
// Get a fresh tracker to show the reset state
|
|
599
|
+
const freshTracker = (0, grail_budget_tracker_1.getGrailBudgetTracker)(grailBudgetGB);
|
|
600
|
+
const state = freshTracker.getState();
|
|
601
|
+
return `✅ **Grail Budget Reset Successfully!**
|
|
602
|
+
|
|
603
|
+
Budget status after reset:
|
|
604
|
+
- Total bytes scanned: ${state.totalBytesScanned} bytes (0 GB)
|
|
605
|
+
- Budget limit: ${state.budgetLimitGB} GB
|
|
606
|
+
- Remaining budget: ${state.budgetLimitGB} GB
|
|
607
|
+
- Budget exceeded: ${state.isBudgetExceeded ? 'Yes' : 'No'}
|
|
608
|
+
|
|
609
|
+
You can now execute new Grail queries (DQL, etc.) again. If this happens more often, please consider
|
|
610
|
+
|
|
611
|
+
- Optimizing your queries (timeframes, bucket selection, filters)
|
|
612
|
+
- Creating or optimizing bucket configurations that fit your queries (see https://docs.dynatrace.com/docs/analyze-explore-automate/logs/lma-bucket-assignment for details)
|
|
613
|
+
- Increasing \`DT_GRAIL_QUERY_BUDGET_GB\` in your environment configuration
|
|
614
|
+
`;
|
|
615
|
+
});
|
|
616
|
+
tool('send_email', 'Send an email using the Dynatrace Email API. The sender will be no-reply@apps.dynatrace.com. Maximum 10 recipients total across TO, CC, and BCC.', {
|
|
617
|
+
toRecipients: zod_1.z.array(zod_1.z.string().email()).describe('Array of email addresses for TO recipients'),
|
|
618
|
+
ccRecipients: zod_1.z.array(zod_1.z.string().email()).optional().describe('Array of email addresses for CC recipients'),
|
|
619
|
+
bccRecipients: zod_1.z.array(zod_1.z.string().email()).optional().describe('Array of email addresses for BCC recipients'),
|
|
620
|
+
subject: zod_1.z.string().describe('Subject line of the email'),
|
|
621
|
+
body: zod_1.z.string().describe('Body content of the email (plain text only)'),
|
|
622
|
+
}, {
|
|
623
|
+
openWorldHint: true, // email is as close to the open-world as we can get with our system
|
|
624
|
+
}, async ({ toRecipients, ccRecipients, bccRecipients, subject, body }) => {
|
|
625
|
+
// Validate total recipients limit (10 max across TO, CC, and BCC)
|
|
626
|
+
const totalRecipients = toRecipients.length + (ccRecipients?.length || 0) + (bccRecipients?.length || 0);
|
|
627
|
+
if (totalRecipients > 10) {
|
|
628
|
+
throw new Error(`Total recipients (${totalRecipients}) exceeds maximum limit of 10 across TO, CC, and BCC fields`);
|
|
629
|
+
}
|
|
630
|
+
const dtClient = await (0, dynatrace_clients_1.createDtHttpClient)(dtEnvironment, scopesBase.concat('email:emails:send'), oauthClientId, oauthClientSecret, dtPlatformToken);
|
|
631
|
+
const emailRequest = {
|
|
632
|
+
toRecipients: { emailAddresses: toRecipients },
|
|
633
|
+
...(ccRecipients && { ccRecipients: { emailAddresses: ccRecipients } }),
|
|
634
|
+
...(bccRecipients && { bccRecipients: { emailAddresses: bccRecipients } }),
|
|
635
|
+
subject,
|
|
636
|
+
body: {
|
|
637
|
+
contentType: 'text/plain',
|
|
638
|
+
body,
|
|
639
|
+
},
|
|
640
|
+
};
|
|
641
|
+
const result = await (0, send_email_1.sendEmail)(dtClient, emailRequest);
|
|
642
|
+
// Format the structured response into a user-friendly string
|
|
643
|
+
let responseMessage = `Email send request accepted. Request ID: ${result.requestId}\n`;
|
|
644
|
+
responseMessage += `Message: ${result.message}\n`;
|
|
645
|
+
if (result.invalidDestinations && result.invalidDestinations.length > 0) {
|
|
646
|
+
responseMessage += `Invalid destinations: ${result.invalidDestinations.join(', ')}\n`;
|
|
647
|
+
}
|
|
648
|
+
if (result.bouncingDestinations && result.bouncingDestinations.length > 0) {
|
|
649
|
+
responseMessage += `Bouncing destinations: ${result.bouncingDestinations.join(', ')}\n`;
|
|
650
|
+
}
|
|
651
|
+
if (result.complainingDestinations && result.complainingDestinations.length > 0) {
|
|
652
|
+
responseMessage += `Complaining destinations: ${result.complainingDestinations.join(', ')}\n`;
|
|
653
|
+
}
|
|
654
|
+
responseMessage += `\nNext Steps:\n- Delivery is asynchronous.\n- Investigate any invalid, bouncing, or complaining destinations before retrying.`;
|
|
655
|
+
return responseMessage;
|
|
656
|
+
});
|
|
523
657
|
// Parse command line arguments using commander
|
|
524
658
|
const program = new commander_1.Command();
|
|
525
659
|
program
|
|
@@ -535,14 +669,23 @@ const main = async () => {
|
|
|
535
669
|
const httpMode = options.http || options.server;
|
|
536
670
|
const httpPort = parseInt(options.port, 10);
|
|
537
671
|
const host = options.host || '0.0.0.0';
|
|
672
|
+
// HTTP server mode (Stateless)
|
|
538
673
|
if (httpMode) {
|
|
539
|
-
// HTTP server mode
|
|
540
|
-
const httpTransport = new streamableHttp_js_1.StreamableHTTPServerTransport({
|
|
541
|
-
sessionIdGenerator: () => (0, node_crypto_1.randomUUID)(),
|
|
542
|
-
});
|
|
543
674
|
const httpServer = (0, node_http_1.createServer)(async (req, res) => {
|
|
544
675
|
// Parse request body for POST requests
|
|
545
676
|
let body;
|
|
677
|
+
// Create a new Stateless HTTP Transport
|
|
678
|
+
const httpTransport = new streamableHttp_js_1.StreamableHTTPServerTransport({
|
|
679
|
+
sessionIdGenerator: undefined, // No Session ID needed
|
|
680
|
+
});
|
|
681
|
+
res.on('close', () => {
|
|
682
|
+
// close transport and server, but not the httpServer itself
|
|
683
|
+
httpTransport.close();
|
|
684
|
+
server.close();
|
|
685
|
+
});
|
|
686
|
+
// Connecting MCP-server to HTTP transport
|
|
687
|
+
await server.connect(httpTransport);
|
|
688
|
+
// Handle POST Requests for this endpoint
|
|
546
689
|
if (req.method === 'POST') {
|
|
547
690
|
const chunks = [];
|
|
548
691
|
for await (const chunk of req) {
|
|
@@ -560,8 +703,6 @@ const main = async () => {
|
|
|
560
703
|
}
|
|
561
704
|
await httpTransport.handleRequest(req, res, body);
|
|
562
705
|
});
|
|
563
|
-
console.error('Connecting server to HTTP transport...');
|
|
564
|
-
await server.connect(httpTransport);
|
|
565
706
|
// Start HTTP Server on the specified host and port
|
|
566
707
|
httpServer.listen(httpPort, host, () => {
|
|
567
708
|
console.error(`Dynatrace MCP Server running on HTTP at http://${host}:${httpPort}`);
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Grail Budget Tracker - tracks and limits bytes scanned by Grail queries (DQL, etc.)
|
|
4
|
+
*/
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.getGrailBudgetTracker = getGrailBudgetTracker;
|
|
7
|
+
exports.resetGrailBudgetTracker = resetGrailBudgetTracker;
|
|
8
|
+
exports.createGrailBudgetTracker = createGrailBudgetTracker;
|
|
9
|
+
exports.formatBytesAsGB = formatBytesAsGB;
|
|
10
|
+
exports.generateBudgetWarning = generateBudgetWarning;
|
|
11
|
+
/**
|
|
12
|
+
* In-memory tracker for Grail budget across the session
|
|
13
|
+
*/
|
|
14
|
+
class GrailBudgetTrackerImpl {
|
|
15
|
+
_totalBytesScanned = 0;
|
|
16
|
+
_budgetLimitBytes;
|
|
17
|
+
_budgetLimitGB;
|
|
18
|
+
_unlimited;
|
|
19
|
+
constructor(budgetLimitGB) {
|
|
20
|
+
this._budgetLimitGB = budgetLimitGB;
|
|
21
|
+
this._unlimited = budgetLimitGB === -1;
|
|
22
|
+
this._budgetLimitBytes = this._unlimited ? Number.POSITIVE_INFINITY : budgetLimitGB * 1000 * 1000 * 1000; // Convert GB to bytes (base 1000)
|
|
23
|
+
}
|
|
24
|
+
get totalBytesScanned() {
|
|
25
|
+
return this._totalBytesScanned;
|
|
26
|
+
}
|
|
27
|
+
get budgetLimitBytes() {
|
|
28
|
+
return this._unlimited ? -1 : this._budgetLimitBytes;
|
|
29
|
+
}
|
|
30
|
+
get budgetLimitGB() {
|
|
31
|
+
return this._budgetLimitGB;
|
|
32
|
+
}
|
|
33
|
+
get isBudgetExceeded() {
|
|
34
|
+
return this._unlimited ? false : this._totalBytesScanned >= this._budgetLimitBytes;
|
|
35
|
+
}
|
|
36
|
+
get remainingBudgetBytes() {
|
|
37
|
+
return this._unlimited ? -1 : Math.max(0, this._budgetLimitBytes - this._totalBytesScanned);
|
|
38
|
+
}
|
|
39
|
+
get remainingBudgetGB() {
|
|
40
|
+
return this._unlimited ? -1 : this.remainingBudgetBytes / (1000 * 1000 * 1000);
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Add bytes scanned to the tracker
|
|
44
|
+
* @param bytesScanned Number of bytes scanned in the Grail query
|
|
45
|
+
* @returns Updated tracker state
|
|
46
|
+
*/
|
|
47
|
+
addBytesScanned(bytesScanned) {
|
|
48
|
+
this._totalBytesScanned += bytesScanned;
|
|
49
|
+
return this.getState();
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Get current state of the tracker
|
|
53
|
+
*/
|
|
54
|
+
getState() {
|
|
55
|
+
return {
|
|
56
|
+
totalBytesScanned: this.totalBytesScanned,
|
|
57
|
+
budgetLimitBytes: this.budgetLimitBytes,
|
|
58
|
+
budgetLimitGB: this.budgetLimitGB,
|
|
59
|
+
isBudgetExceeded: this.isBudgetExceeded,
|
|
60
|
+
remainingBudgetBytes: this.remainingBudgetBytes,
|
|
61
|
+
remainingBudgetGB: this.remainingBudgetGB,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Reset the tracker (for testing purposes)
|
|
66
|
+
*/
|
|
67
|
+
reset() {
|
|
68
|
+
this._totalBytesScanned = 0;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
// Global instance for the current session
|
|
72
|
+
let globalBudgetTracker = null;
|
|
73
|
+
/**
|
|
74
|
+
* Initialize or get the global Grail budget tracker
|
|
75
|
+
* @param budgetLimitGB Budget limit in GB (base 1000). If not provided and tracker doesn't exist, defaults to 1000 GB
|
|
76
|
+
* @returns Grail budget tracker instance
|
|
77
|
+
*/
|
|
78
|
+
function getGrailBudgetTracker(budgetLimitGB) {
|
|
79
|
+
if (!globalBudgetTracker) {
|
|
80
|
+
const defaultBudget = budgetLimitGB ?? 1000; // Default to 1000 GB if not specified
|
|
81
|
+
globalBudgetTracker = new GrailBudgetTrackerImpl(defaultBudget);
|
|
82
|
+
}
|
|
83
|
+
return globalBudgetTracker;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Reset the global Grail budget tracker (primarily for testing)
|
|
87
|
+
*/
|
|
88
|
+
function resetGrailBudgetTracker() {
|
|
89
|
+
globalBudgetTracker = null;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Create a new Grail budget tracker instance (for testing)
|
|
93
|
+
* @param budgetLimitGB Budget limit in GB (base 1000)
|
|
94
|
+
* @returns New Grail budget tracker instance
|
|
95
|
+
*/
|
|
96
|
+
function createGrailBudgetTracker(budgetLimitGB) {
|
|
97
|
+
return new GrailBudgetTrackerImpl(budgetLimitGB);
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Format bytes as GB with appropriate precision
|
|
101
|
+
* @param bytes Number of bytes
|
|
102
|
+
* @returns Formatted string with GB value
|
|
103
|
+
*/
|
|
104
|
+
function formatBytesAsGB(bytes) {
|
|
105
|
+
const gb = bytes / (1000 * 1000 * 1000);
|
|
106
|
+
if (gb >= 10) {
|
|
107
|
+
return gb.toFixed(1);
|
|
108
|
+
}
|
|
109
|
+
else if (gb >= 1) {
|
|
110
|
+
return gb.toFixed(2);
|
|
111
|
+
}
|
|
112
|
+
else if (gb >= 0.1) {
|
|
113
|
+
return gb.toFixed(3);
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
return gb.toFixed(4);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Generate a budget warning message based on current state
|
|
121
|
+
* @param budgetState Current budget tracker state
|
|
122
|
+
* @param currentQueryBytes Bytes scanned in the current query
|
|
123
|
+
* @returns Warning message or null if no warning needed
|
|
124
|
+
*/
|
|
125
|
+
function generateBudgetWarning(budgetState, currentQueryBytes) {
|
|
126
|
+
if (budgetState.isBudgetExceeded) {
|
|
127
|
+
const totalGB = formatBytesAsGB(budgetState.totalBytesScanned);
|
|
128
|
+
const currentGB = formatBytesAsGB(currentQueryBytes);
|
|
129
|
+
return `🚨 **Grail Budget Exceeded:** This query scanned ${currentGB} GB. Total session usage: ${totalGB} GB / ${budgetState.budgetLimitGB} GB budget limit. You will not be able to perform any more queries in this session.`;
|
|
130
|
+
}
|
|
131
|
+
// Warning when approaching budget (80% threshold)
|
|
132
|
+
const usagePercentage = (budgetState.totalBytesScanned / budgetState.budgetLimitBytes) * 100;
|
|
133
|
+
if (usagePercentage >= 80) {
|
|
134
|
+
const remainingGB = formatBytesAsGB(budgetState.remainingBudgetBytes);
|
|
135
|
+
const totalGB = formatBytesAsGB(budgetState.totalBytesScanned);
|
|
136
|
+
return `⚠️ **Grail Budget Warning:** Session usage: ${totalGB} GB / ${budgetState.budgetLimitGB} GB (${usagePercentage.toFixed(1)}%). Remaining: ${remainingGB} GB.`;
|
|
137
|
+
}
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const grail_budget_tracker_1 = require("./grail-budget-tracker");
|
|
4
|
+
describe('Grail Budget Tracker', () => {
|
|
5
|
+
describe('unlimited budget (-1)', () => {
|
|
6
|
+
it('should never exceed budget and always allow queries when budget is -1', () => {
|
|
7
|
+
const tracker = (0, grail_budget_tracker_1.createGrailBudgetTracker)(-1);
|
|
8
|
+
expect(tracker.getState().budgetLimitGB).toBe(-1);
|
|
9
|
+
expect(tracker.getState().budgetLimitBytes).toBe(-1);
|
|
10
|
+
expect(tracker.getState().isBudgetExceeded).toBe(false);
|
|
11
|
+
expect(tracker.getState().remainingBudgetBytes).toBe(-1);
|
|
12
|
+
expect(tracker.getState().remainingBudgetGB).toBe(-1);
|
|
13
|
+
// Add a huge number of bytes, should still not be exceeded
|
|
14
|
+
tracker.addBytesScanned(1e15);
|
|
15
|
+
expect(tracker.getState().isBudgetExceeded).toBe(false);
|
|
16
|
+
expect(tracker.getState().remainingBudgetBytes).toBe(-1);
|
|
17
|
+
expect(tracker.getState().remainingBudgetGB).toBe(-1);
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
(0, grail_budget_tracker_1.resetGrailBudgetTracker)();
|
|
22
|
+
});
|
|
23
|
+
describe('createGrailBudgetTracker', () => {
|
|
24
|
+
it('should create a tracker with the specified budget limit', () => {
|
|
25
|
+
const tracker = (0, grail_budget_tracker_1.createGrailBudgetTracker)(5);
|
|
26
|
+
const state = tracker.getState();
|
|
27
|
+
expect(state.budgetLimitGB).toBe(5);
|
|
28
|
+
expect(state.budgetLimitBytes).toBe(5000000000); // 5 GB in bytes (base 1000)
|
|
29
|
+
expect(state.totalBytesScanned).toBe(0);
|
|
30
|
+
expect(state.isBudgetExceeded).toBe(false);
|
|
31
|
+
expect(state.remainingBudgetGB).toBe(5);
|
|
32
|
+
});
|
|
33
|
+
it('should correctly track bytes scanned', () => {
|
|
34
|
+
const tracker = (0, grail_budget_tracker_1.createGrailBudgetTracker)(1); // 1 GB limit
|
|
35
|
+
const bytesToScan = 500000000; // 0.5 GB
|
|
36
|
+
const state = tracker.addBytesScanned(bytesToScan);
|
|
37
|
+
expect(state.totalBytesScanned).toBe(bytesToScan);
|
|
38
|
+
expect(state.remainingBudgetBytes).toBe(500000000); // 0.5 GB remaining
|
|
39
|
+
expect(state.remainingBudgetGB).toBe(0.5);
|
|
40
|
+
expect(state.isBudgetExceeded).toBe(false);
|
|
41
|
+
});
|
|
42
|
+
it('should detect when budget is exceeded', () => {
|
|
43
|
+
const tracker = (0, grail_budget_tracker_1.createGrailBudgetTracker)(1); // 1 GB limit
|
|
44
|
+
const bytesToScan = 1500000000; // 1.5 GB
|
|
45
|
+
const state = tracker.addBytesScanned(bytesToScan);
|
|
46
|
+
expect(state.totalBytesScanned).toBe(bytesToScan);
|
|
47
|
+
expect(state.isBudgetExceeded).toBe(true);
|
|
48
|
+
expect(state.remainingBudgetBytes).toBe(0);
|
|
49
|
+
expect(state.remainingBudgetGB).toBe(0);
|
|
50
|
+
});
|
|
51
|
+
it('should accumulate bytes scanned across multiple calls', () => {
|
|
52
|
+
const tracker = (0, grail_budget_tracker_1.createGrailBudgetTracker)(2); // 2 GB limit
|
|
53
|
+
tracker.addBytesScanned(500000000); // 0.5 GB
|
|
54
|
+
tracker.addBytesScanned(700000000); // 0.7 GB
|
|
55
|
+
const state = tracker.addBytesScanned(300000000); // 0.3 GB
|
|
56
|
+
expect(state.totalBytesScanned).toBe(1500000000); // 1.5 GB total
|
|
57
|
+
expect(state.remainingBudgetBytes).toBe(500000000); // 0.5 GB remaining
|
|
58
|
+
expect(state.isBudgetExceeded).toBe(false);
|
|
59
|
+
});
|
|
60
|
+
it('should reset tracker correctly', () => {
|
|
61
|
+
const tracker = (0, grail_budget_tracker_1.createGrailBudgetTracker)(1);
|
|
62
|
+
tracker.addBytesScanned(500000000);
|
|
63
|
+
tracker.reset();
|
|
64
|
+
const state = tracker.getState();
|
|
65
|
+
expect(state.totalBytesScanned).toBe(0);
|
|
66
|
+
expect(state.remainingBudgetGB).toBe(1);
|
|
67
|
+
expect(state.isBudgetExceeded).toBe(false);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
describe('getGrailBudgetTracker', () => {
|
|
71
|
+
it('should create a global tracker with default budget when called first time', () => {
|
|
72
|
+
const tracker = (0, grail_budget_tracker_1.getGrailBudgetTracker)();
|
|
73
|
+
const state = tracker.getState();
|
|
74
|
+
expect(state.budgetLimitGB).toBe(1000); // Default budget
|
|
75
|
+
});
|
|
76
|
+
it('should create a global tracker with specified budget when called first time', () => {
|
|
77
|
+
const tracker = (0, grail_budget_tracker_1.getGrailBudgetTracker)(15);
|
|
78
|
+
const state = tracker.getState();
|
|
79
|
+
expect(state.budgetLimitGB).toBe(15);
|
|
80
|
+
});
|
|
81
|
+
it('should return the same global tracker instance on subsequent calls', () => {
|
|
82
|
+
const tracker1 = (0, grail_budget_tracker_1.getGrailBudgetTracker)(5);
|
|
83
|
+
const tracker2 = (0, grail_budget_tracker_1.getGrailBudgetTracker)(20); // Different budget, but should return existing
|
|
84
|
+
expect(tracker1).toBe(tracker2);
|
|
85
|
+
expect(tracker1.getState().budgetLimitGB).toBe(5); // Should keep original budget
|
|
86
|
+
});
|
|
87
|
+
it('should persist state across getGrailBudgetTracker calls', () => {
|
|
88
|
+
const tracker1 = (0, grail_budget_tracker_1.getGrailBudgetTracker)(3);
|
|
89
|
+
tracker1.addBytesScanned(1000000000); // 1 GB
|
|
90
|
+
const tracker2 = (0, grail_budget_tracker_1.getGrailBudgetTracker)();
|
|
91
|
+
const state = tracker2.getState();
|
|
92
|
+
expect(state.totalBytesScanned).toBe(1000000000);
|
|
93
|
+
expect(state.budgetLimitGB).toBe(3);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
describe('formatBytesAsGB', () => {
|
|
97
|
+
it('should format large GB values with 1 decimal place', () => {
|
|
98
|
+
expect((0, grail_budget_tracker_1.formatBytesAsGB)(15000000000)).toBe('15.0'); // 15 GB
|
|
99
|
+
expect((0, grail_budget_tracker_1.formatBytesAsGB)(123456789000)).toBe('123.5'); // ~123.5 GB
|
|
100
|
+
});
|
|
101
|
+
it('should format medium GB values with 2 decimal places', () => {
|
|
102
|
+
expect((0, grail_budget_tracker_1.formatBytesAsGB)(1500000000)).toBe('1.50'); // 1.5 GB
|
|
103
|
+
expect((0, grail_budget_tracker_1.formatBytesAsGB)(5230000000)).toBe('5.23'); // 5.23 GB
|
|
104
|
+
});
|
|
105
|
+
it('should format small GB values with 3 decimal places', () => {
|
|
106
|
+
expect((0, grail_budget_tracker_1.formatBytesAsGB)(150000000)).toBe('0.150'); // 0.15 GB
|
|
107
|
+
expect((0, grail_budget_tracker_1.formatBytesAsGB)(523000000)).toBe('0.523'); // 0.523 GB
|
|
108
|
+
});
|
|
109
|
+
it('should format very small GB values with 4 decimal places', () => {
|
|
110
|
+
expect((0, grail_budget_tracker_1.formatBytesAsGB)(15000000)).toBe('0.0150'); // 0.015 GB
|
|
111
|
+
expect((0, grail_budget_tracker_1.formatBytesAsGB)(1500000)).toBe('0.0015'); // 0.0015 GB
|
|
112
|
+
});
|
|
113
|
+
it('should handle zero bytes', () => {
|
|
114
|
+
expect((0, grail_budget_tracker_1.formatBytesAsGB)(0)).toBe('0.0000');
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
describe('generateBudgetWarning', () => {
|
|
118
|
+
it('should return budget exceeded warning when budget is exceeded', () => {
|
|
119
|
+
const tracker = (0, grail_budget_tracker_1.createGrailBudgetTracker)(1); // 1 GB limit
|
|
120
|
+
tracker.addBytesScanned(1200000000); // 1.2 GB
|
|
121
|
+
const state = tracker.getState();
|
|
122
|
+
const warning = (0, grail_budget_tracker_1.generateBudgetWarning)(state, 200000000); // Current query: 0.2 GB
|
|
123
|
+
expect(warning).toContain('🚨 **Grail Budget Exceeded:**');
|
|
124
|
+
expect(warning).toContain('1.20 GB');
|
|
125
|
+
expect(warning).toContain('1 GB budget limit');
|
|
126
|
+
expect(warning).toContain('0.200 GB');
|
|
127
|
+
});
|
|
128
|
+
it('should return warning when approaching budget (80% threshold)', () => {
|
|
129
|
+
const tracker = (0, grail_budget_tracker_1.createGrailBudgetTracker)(1); // 1 GB limit
|
|
130
|
+
tracker.addBytesScanned(850000000); // 0.85 GB (85%)
|
|
131
|
+
const state = tracker.getState();
|
|
132
|
+
const warning = (0, grail_budget_tracker_1.generateBudgetWarning)(state, 50000000); // Current query: 0.05 GB
|
|
133
|
+
expect(warning).toContain('⚠️ **Grail Budget Warning:**');
|
|
134
|
+
expect(warning).toContain('85.0%');
|
|
135
|
+
expect(warning).toContain('0.150 GB'); // Remaining
|
|
136
|
+
});
|
|
137
|
+
it('should return no warning when well below budget', () => {
|
|
138
|
+
const tracker = (0, grail_budget_tracker_1.createGrailBudgetTracker)(1); // 1 GB limit
|
|
139
|
+
tracker.addBytesScanned(300000000); // 0.3 GB (30%)
|
|
140
|
+
const state = tracker.getState();
|
|
141
|
+
const warning = (0, grail_budget_tracker_1.generateBudgetWarning)(state, 100000000); // Current query: 0.1 GB
|
|
142
|
+
expect(warning).toBeNull();
|
|
143
|
+
});
|
|
144
|
+
it('should return warning when exactly at 80% threshold', () => {
|
|
145
|
+
const tracker = (0, grail_budget_tracker_1.createGrailBudgetTracker)(1); // 1 GB limit
|
|
146
|
+
tracker.addBytesScanned(800000000); // 0.8 GB (80%)
|
|
147
|
+
const state = tracker.getState();
|
|
148
|
+
const warning = (0, grail_budget_tracker_1.generateBudgetWarning)(state, 0);
|
|
149
|
+
expect(warning).toContain('⚠️ **Grail Budget Warning:**');
|
|
150
|
+
expect(warning).toContain('80.0%');
|
|
151
|
+
});
|
|
152
|
+
it('should prioritize exceeded warning over approaching warning', () => {
|
|
153
|
+
const tracker = (0, grail_budget_tracker_1.createGrailBudgetTracker)(1); // 1 GB limit
|
|
154
|
+
tracker.addBytesScanned(1100000000); // 1.1 GB (110% - exceeded)
|
|
155
|
+
const state = tracker.getState();
|
|
156
|
+
const warning = (0, grail_budget_tracker_1.generateBudgetWarning)(state, 100000000);
|
|
157
|
+
expect(warning).toContain('🚨 **Grail Budget Exceeded:**');
|
|
158
|
+
expect(warning).not.toContain('⚠️ **Grail Budget Warning:**');
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
describe('edge cases', () => {
|
|
162
|
+
it('should handle fractional GB budgets', () => {
|
|
163
|
+
const tracker = (0, grail_budget_tracker_1.createGrailBudgetTracker)(0.5); // 0.5 GB limit
|
|
164
|
+
const state = tracker.getState();
|
|
165
|
+
expect(state.budgetLimitGB).toBe(0.5);
|
|
166
|
+
expect(state.budgetLimitBytes).toBe(500000000); // 0.5 GB in bytes
|
|
167
|
+
});
|
|
168
|
+
it('should handle very large budgets', () => {
|
|
169
|
+
const tracker = (0, grail_budget_tracker_1.createGrailBudgetTracker)(1000); // 1TB limit
|
|
170
|
+
const state = tracker.getState();
|
|
171
|
+
expect(state.budgetLimitGB).toBe(1000);
|
|
172
|
+
expect(state.budgetLimitBytes).toBe(1000000000000); // 1TB in bytes
|
|
173
|
+
});
|
|
174
|
+
it('should handle zero bytes scanned', () => {
|
|
175
|
+
const tracker = (0, grail_budget_tracker_1.createGrailBudgetTracker)(1);
|
|
176
|
+
const state = tracker.addBytesScanned(0);
|
|
177
|
+
expect(state.totalBytesScanned).toBe(0);
|
|
178
|
+
expect(state.isBudgetExceeded).toBe(false);
|
|
179
|
+
expect(state.remainingBudgetGB).toBe(1);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dynatrace-oss/dynatrace-mcp-server",
|
|
3
|
-
"version": "0.6.0
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"mcpName": "io.github.dynatrace-oss/Dynatrace-mcp",
|
|
5
5
|
"description": "Model Context Protocol (MCP) server for Dynatrace",
|
|
6
6
|
"keywords": [
|
|
@@ -48,6 +48,7 @@
|
|
|
48
48
|
"license": "MIT",
|
|
49
49
|
"dependencies": {
|
|
50
50
|
"@dynatrace-sdk/client-automation": "^5.3.0",
|
|
51
|
+
"@dynatrace-sdk/client-davis-copilot": "^1.0.0",
|
|
51
52
|
"@dynatrace-sdk/client-platform-management-service": "^1.6.3",
|
|
52
53
|
"@dynatrace-sdk/client-query": "^1.18.1",
|
|
53
54
|
"@dynatrace-sdk/shared-errors": "^1.0.0",
|