@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 CHANGED
@@ -18,14 +18,30 @@
18
18
  </a>
19
19
  </h4>
20
20
 
21
- This local MCP server allows interaction with the [Dynatrace](https://www.dynatrace.com/) observability platform.
22
- Bring real-time observability data directly into your development workflow.
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
- Please contact us via [GitHub Issues](https://github.com/dynatrace-oss/dynatrace-mcp/issues) if you have feature requests, questions, or need help.
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
- <img width="1046" alt="image" src="/assets/dynatrace-mcp-arch.png" />
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
+ ![Architecture](https://github.com/dynatrace-oss/dynatrace-mcp/blob/main/assets/dynatrace-mcp-arch.png?raw=true)
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
- ## Quickstart
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
- // API Functions
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 request = { text };
13
- const response = await dtClient.send({
14
- method: 'POST',
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 request = { dql };
33
- const response = await dtClient.send({
34
- method: 'POST',
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 request = {
47
- text,
48
- context,
49
- annotations,
50
- state,
51
- };
52
- const response = await dtClient.send({
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
- return await response.body('json');
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: queryResult.metadata?.grail?.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;
@@ -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
- const main = async () => {
53
- // read Environment variables
54
- let dynatraceEnv;
55
- try {
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 = 5;
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
- process.exit(1);
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) * 1000; // Exponential backoff
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.', {}, async ({}) => {
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
- // Cost warning based on scanned bytes
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 += `\n ⚠️ **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`;
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 += `\n ⚠️ **High Data Usage Warning:** This query scanned ${scannedGB.toFixed(2)} GB of data, which may impact your Dynatrace consumption.\n`;
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 += `\n 💡 **Moderate Data Usage:** This query scanned ${scannedGB.toFixed(2)} GB of data.\n`;
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 += `\n 💡 **No Data consumed:** This query did not consume any data.\n`;
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-rc.1",
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",