@dynatrace-oss/dynatrace-mcp-server 0.8.0 → 0.9.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
@@ -29,15 +29,11 @@ https://github.com/user-attachments/assets/25c05db1-8e09-4a7f-add2-ed486ffd4b5a
29
29
 
30
30
  ## Quickstart
31
31
 
32
- 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`.
33
- You can find more details about the configuration for different AI Assistants, Agents and MCP Clients in the [Configuration section below](#configuration).
32
+ You can add this MCP server to your MCP Client like VSCode, Claude, Cursor, Amazon Q, Windsurf, ChatGPT, or Github Copilot via the command is `npx -y @dynatrace-oss/dynatrace-mcp-server` (type: `stdio`). For more details, please refer to the [configuration section below](#configuration).
34
33
 
35
- 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).
36
-
37
- Depending on your MCP Client, you need to configure these as environment variables or as settings in the UI:
34
+ Furthermore, you need to configure the URL to a Dynatrace environment:
38
35
 
39
36
  - `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`)
40
- - `DT_PLATFORM_TOKEN` (string, e.g., `dt0s16.SAMPLE.abcd1234`) - **Recommended**: Dynatrace Platform Token
41
37
 
42
38
  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.
43
39
 
@@ -111,97 +107,6 @@ fetch dt.system.events
111
107
 
112
108
  > **Note:** While Davis CoPilot AI is generally available (GA), the Davis CoPilot APIs are currently in preview. For more information, visit the [Davis CoPilot Preview Community](https://dt-url.net/copilot-community).
113
109
 
114
- ## 🎯 AI-Powered Observability Workshop Rules
115
-
116
- Enhance your AI assistant with comprehensive Dynatrace observability analysis capabilities through our streamlined workshop rules. These rules provide hierarchical workflows for security, compliance, incident response, and distributed systems investigation.
117
-
118
- ### **🚀 Quick Setup for AI Assistants**
119
-
120
- Copy the comprehensive rule files from the [`dynatrace-agent-rules/rules/`](./dynatrace-agent-rules/rules/) directory to your AI assistant's rules directory:
121
-
122
- **IDE-Specific Locations:**
123
-
124
- - **Amazon Q**: `.amazonq/rules/` (project) or `~/.aws/amazonq/rules/` (global)
125
- - **Cursor**: `.cursor/rules/` (project) or via Settings → Rules (global)
126
- - **Windsurf**: `.windsurfrules/` (project) or via Customizations → Rules (global)
127
- - **Cline**: `.clinerules/` (project) or `~/Documents/Cline/Rules/` (global)
128
- - **GitHub Copilot**: `.github/copilot-instructions.md` (project only)
129
-
130
- Then initialize the agent in your AI chat:
131
-
132
- ```
133
- load dynatrace mcp
134
- ```
135
-
136
- ### **🏗️ Enhanced Analysis Capabilities**
137
-
138
- The workshop rules unlock advanced observability analysis modes:
139
-
140
- #### **🚨 Incident Response & Problem Investigation**
141
-
142
- - **4-phase structured investigation** workflow (Detection → Impact → Root Cause → Resolution)
143
- - **Cross-data source correlation** (problems → logs → spans → metrics)
144
- - **Kubernetes-aware incident analysis** with namespace and pod context
145
- - **User impact assessment** with Davis AI integration
146
-
147
- #### **📊 Comprehensive Data Investigation**
148
-
149
- - **Unified log-service-process analysis** in single workflow
150
- - **Business logic error detection** patterns
151
- - **Deployment correlation analysis** with ArgoCD/GitOps integration
152
- - **Golden signals monitoring** (Rate, Errors, Duration, Saturation)
153
-
154
- #### **🔗 Advanced Transaction Analysis**
155
-
156
- - **Precise root cause identification** with file/line numbers
157
- - **Exception stack trace analysis** with business context
158
- - **Multi-service cascade failure analysis**
159
- - **Performance impact correlation** across distributed systems
160
-
161
- #### **🛡️ Enhanced Security & Compliance**
162
-
163
- - **Latest-scan analysis** prevents outdated data aggregation
164
- - **Multi-cloud compliance** (AWS, Azure, GCP, Kubernetes)
165
- - **Evidence-based investigation** with detailed remediation paths
166
- - **Risk-based scoring** with team-specific guidance
167
-
168
- #### **⚡ DevOps Automation & SRE**
169
-
170
- - **Deployment health gates** with automated promotion/rollback
171
- - **SLO/SLI automation** with error budget calculations
172
- - **Infrastructure as Code remediation** with auto-generated templates
173
- - **Alert optimization workflows** with pattern recognition
174
-
175
- ### **📁 Hierarchical Rule Architecture**
176
-
177
- The rules are organized in a context-window optimized structure:
178
-
179
- ```
180
- rules/
181
- ├── DynatraceMcpIntegration.md # 🎯 MAIN ORCHESTRATOR
182
- ├── workflows/ # 🔧 ANALYSIS WORKFLOWS
183
- │ ├── incidentResponse.md # Core incident investigation
184
- │ ├── DynatraceSecurityCompliance.md # Security & compliance analysis
185
- │ ├── DynatraceDevOpsIntegration.md # CI/CD automation
186
- │ └── dataSourceGuides/ # 📊 DATA ANALYSIS GUIDES
187
- │ ├── dataInvestigation.md # Logs, services, processes
188
- │ └── DynatraceSpanAnalysis.md # Transaction tracing
189
- └── reference/ # 📚 TECHNICAL DOCUMENTATION
190
- ├── DynatraceQueryLanguage.md # DQL syntax foundation
191
- ├── DynatraceExplore.md # Field discovery patterns
192
- ├── DynatraceSecurityEvents.md # Security events schema
193
- └── DynatraceProblemsSpec.md # Problems schema reference
194
- ```
195
-
196
- **Key Architectural Benefits:**
197
-
198
- - **All files under 6,500 tokens** - Compatible with most LLM context limits
199
- - **Hierarchical organization** - Clear entry points and specialized guides
200
- - **Eliminated circular references** - No more confusing cross-referencing webs
201
- - **DQL-first approach** - Prefer flexible queries over rigid MCP calls
202
-
203
- For detailed information about the workshop rules, see the [Rules README](./dynatrace-agent-rules/rules/README.md).
204
-
205
110
  ## Configuration
206
111
 
207
112
  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`.
@@ -233,7 +138,6 @@ This only works if the config is stored in the current workspaces, e.g., `<your-
233
138
  "command": "npx",
234
139
  "args": ["-y", "@dynatrace-oss/dynatrace-mcp-server@latest"],
235
140
  "env": {
236
- "DT_PLATFORM_TOKEN": "",
237
141
  "DT_ENVIRONMENT": ""
238
142
  }
239
143
  }
@@ -250,7 +154,6 @@ This only works if the config is stored in the current workspaces, e.g., `<your-
250
154
  "command": "npx",
251
155
  "args": ["-y", "@dynatrace-oss/dynatrace-mcp-server@latest"],
252
156
  "env": {
253
- "DT_PLATFORM_TOKEN": "",
254
157
  "DT_ENVIRONMENT": ""
255
158
  }
256
159
  }
@@ -269,7 +172,6 @@ The [Amazon Q Developer CLI](https://docs.aws.amazon.com/amazonq/latest/qdevelop
269
172
  "command": "npx",
270
173
  "args": ["-y", "@dynatrace-oss/dynatrace-mcp-server@latest"],
271
174
  "env": {
272
- "DT_PLATFORM_TOKEN": "",
273
175
  "DT_ENVIRONMENT": ""
274
176
  }
275
177
  }
@@ -287,7 +189,7 @@ Using `gemini` CLI directly (recommended):
287
189
 
288
190
  ```bash
289
191
  gemini extensions install https://github.com/dynatrace-oss/dynatrace-mcp
290
- export DT_PLATFORM_TOKEN=...
192
+ export DT_PLATFORM_TOKEN=... # optional
291
193
  export DT_ENVIRONMENT=https://...
292
194
  ```
293
195
 
@@ -306,7 +208,6 @@ Or manually in your `~/.gemini/settings.json` or `.gemini/settings.json`:
306
208
  "command": "npx",
307
209
  "args": ["@dynatrace-oss/dynatrace-mcp-server@latest"],
308
210
  "env": {
309
- "DT_PLATFORM_TOKEN": "",
310
211
  "DT_ENVIRONMENT": ""
311
212
  },
312
213
  "timeout": 30000,
@@ -381,17 +282,15 @@ For fetching just error-logs, add `| filter loglevel == "ERROR"`.
381
282
 
382
283
  ## Environment Variables
383
284
 
384
- You can set up authentication via **Platform Tokens** (recommended) or **OAuth Client** via the following environment variables:
385
-
386
- - `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`)
387
- - `DT_PLATFORM_TOKEN` (string, e.g., `dt0s16.SAMPLE.abcd1234`) - **Recommended**: Dynatrace Platform Token
388
- - `OAUTH_CLIENT_ID` (string, e.g., `dt0s02.SAMPLE`) - Alternative: Dynatrace OAuth Client ID (for advanced use cases)
389
- - `OAUTH_CLIENT_SECRET` (string, e.g., `dt0s02.SAMPLE.abcd1234`) - Alternative: Dynatrace OAuth Client Secret (for advanced use cases)
390
- - `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.
285
+ - `DT_ENVIRONMENT` (**required**, string, e.g., `https://abc12345.apps.dynatrace.com`) - URL to your Dynatrace Platform (do not use Dynatrace classic URLs like `abc12345.live.dynatrace.com`)
286
+ - `DT_PLATFORM_TOKEN` (optional, string, e.g., `dt0s16.SAMPLE.abcd1234`) - Dynatrace Platform Token
287
+ - `OAUTH_CLIENT_ID` (optional, string, e.g., `dt0s02.SAMPLE`) - Alternative: Dynatrace OAuth Client ID (for advanced use cases)
288
+ - `OAUTH_CLIENT_SECRET` (optional, string, e.g., `dt0s02.SAMPLE.abcd1234`) - Alternative: Dynatrace OAuth Client Secret (for advanced use cases)
289
+ - `DT_GRAIL_QUERY_BUDGET_GB` (optional, 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.
391
290
 
392
- **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.
291
+ When just providing `DT_ENVIRONMENT`, the local MCP server will try to open a browser window to authenticate against the Dynatrace SSO.
393
292
 
394
- For more information, please have a look at the documentation about
293
+ For more information about the other authentication methods, please have a look at the documentation about
395
294
  [creating a Platform Token in Dynatrace](https://docs.dynatrace.com/docs/manage/identity-access-management/access-tokens-and-oauth-clients/platform-tokens), as well as
396
295
  [creating an OAuth Client in Dynatrace](https://docs.dynatrace.com/docs/manage/identity-access-management/access-tokens-and-oauth-clients/oauth-clients) for advanced scenarios.
397
296
 
@@ -4,37 +4,13 @@ exports.createDtHttpClient = void 0;
4
4
  const http_client_1 = require("@dynatrace-sdk/http-client");
5
5
  const dt_app_1 = require("dt-app");
6
6
  const user_agent_1 = require("../utils/user-agent");
7
- /**
8
- * Uses the provided oauth Client ID and Secret and requests a token via client-credentials flow
9
- * @param clientId - OAuth Client ID for Dynatrace
10
- * @param clientSecret - Oauth Client Secret for Dynatrace
11
- * @param ssoAuthUrl - SSO Authentication URL
12
- * @param scopes - List of requested scopes
13
- * @returns Response of the OAuth Endpoint (which, in the best case includes a token)
14
- */
15
- const requestToken = async (clientId, clientSecret, ssoAuthUrl, scopes) => {
16
- const res = await fetch(ssoAuthUrl, {
17
- method: 'POST',
18
- headers: {
19
- 'Content-Type': 'application/x-www-form-urlencoded',
20
- },
21
- body: new URLSearchParams({
22
- grant_type: 'client_credentials',
23
- client_id: clientId,
24
- client_secret: clientSecret,
25
- scope: scopes.join(' '),
26
- }),
27
- });
28
- // check if the response was okay (HTTP 2xx) or not (HTTP 4xx or 5xx)
29
- if (!res.ok) {
30
- // log the error
31
- console.error(`Failed to fetch token: ${res.status} ${res.statusText}`);
32
- }
33
- // and return the JSON result, as it contains additional information
34
- return await res.json();
35
- };
7
+ const dynatrace_oauth_auth_code_flow_1 = require("./dynatrace-oauth-auth-code-flow");
8
+ const token_cache_1 = require("./token-cache");
9
+ const utils_1 = require("./utils");
10
+ const dynatrace_oauth_client_credentials_1 = require("./dynatrace-oauth-client-credentials");
36
11
  /**
37
12
  * Create a Dynatrace Http Client (from the http-client SDK) based on the provided authentication credentials
13
+ * Supports Platform Token, OAuth Client Credentials Flow, and OAuth Authorization Code Flow (interactive)
38
14
  * @param environmentUrl
39
15
  * @param scopes
40
16
  * @param clientId
@@ -43,50 +19,128 @@ const requestToken = async (clientId, clientSecret, ssoAuthUrl, scopes) => {
43
19
  * @returns an authenticated HttpClient
44
20
  */
45
21
  const createDtHttpClient = async (environmentUrl, scopes, clientId, clientSecret, dtPlatformToken) => {
46
- if (clientId && clientSecret) {
47
- // create an OAuth client if clientId and clientSecret are provided
48
- return createOAuthHttpClient(environmentUrl, scopes, clientId, clientSecret);
49
- }
22
+ /** Logic:
23
+ * * if a platform token is provided, use it
24
+ * * If no platform token is provided, but clientId and clientSecret are provided, use client credentials flow
25
+ * * If no platform token is provided, and no clientSecret is provided, but a clientId is provided, use OAuth authorization code flow (interactive)
26
+ * * If neither platform token nor OAuth credentials are provided, throw an error
27
+ */
50
28
  if (dtPlatformToken) {
51
29
  // create a simple HTTP client if only the platform token is provided
52
- return createBearerTokenHttpClient(environmentUrl, dtPlatformToken);
30
+ return createPlatformTokenHttpClient(environmentUrl, dtPlatformToken);
53
31
  }
54
- throw new Error('Failed to create Dynatrace HTTP Client: Please provide either clientId and clientSecret or dtPlatformToken');
32
+ else if (clientId && clientSecret) {
33
+ // create an Oauth client using client credentials flow (non-interactive)
34
+ return createOAuthClientCredentialsHttpClient(environmentUrl, scopes, clientId, clientSecret);
35
+ }
36
+ else if (clientId) {
37
+ // create an OAuth client using authorization code flow (interactive)
38
+ return createOAuthAuthCodeFlowHttpClient(environmentUrl, scopes, clientId);
39
+ }
40
+ throw new Error('Failed to create Dynatrace HTTP Client: Please provide either clientId and clientSecret for client credentials flow, clientId only for interactive OAuth flow, or just a platform token.');
55
41
  };
56
42
  exports.createDtHttpClient = createDtHttpClient;
57
- /** Creates an HTTP Client based on environmentUrl and a platform token */
58
- const createBearerTokenHttpClient = async (environmentUrl, dtPlatformToken) => {
43
+ /**
44
+ * Creates an HTTP Client based on environmentUrl and a bearer token, and also sets the user agent
45
+ */
46
+ const createBearerTokenHttpClient = async (environmentUrl, bearerToken) => {
59
47
  return new http_client_1.PlatformHttpClient({
60
48
  baseUrl: environmentUrl,
61
49
  defaultHeaders: {
62
- 'Authorization': `Bearer ${dtPlatformToken}`,
50
+ 'Authorization': `Bearer ${bearerToken}`,
63
51
  'User-Agent': (0, user_agent_1.getUserAgent)(),
64
52
  },
65
53
  });
66
54
  };
67
- /** Create an Oauth Client based on clientId, clientSecret, environmentUrl and scopes
55
+ /**
56
+ * Creates an HTTP Client based on environmentUrl and a platform token (as bearer token)
57
+ */
58
+ const createPlatformTokenHttpClient = async (environmentUrl, dtPlatformToken) => {
59
+ console.error(`🔒 Using Platform Token to authenticate API Calls to ${environmentUrl}`);
60
+ return createBearerTokenHttpClient(environmentUrl, dtPlatformToken);
61
+ };
62
+ /**
63
+ * Create an OAuth Client based on clientId, clientSecret, environmentUrl and scopes
68
64
  * This uses a client-credentials flow to request a token from the SSO endpoint.
65
+ * Note: We do not refresh the token here, we always request a new one on each client creation.
69
66
  */
70
- const createOAuthHttpClient = async (environmentUrl, scopes, clientId, clientSecret) => {
71
- if (!clientId) {
72
- throw new Error('Failed to retrieve OAuth client id from env "DT_APP_OAUTH_CLIENT_ID"');
73
- }
74
- if (!clientSecret) {
75
- throw new Error('Failed to retrieve OAuth client secret from env "DT_APP_OAUTH_CLIENT_SECRET"');
76
- }
77
- if (!environmentUrl) {
78
- throw new Error('Failed to retrieve environment URL from env "DT_ENVIRONMENT"');
79
- }
80
- console.error(`Trying to authenticate API Calls to ${environmentUrl} via OAuthClientId ${clientId} with the following scopes: ${scopes.join(', ')}`);
81
- const ssoBaseUrl = await (0, dt_app_1.getSSOUrl)(environmentUrl);
82
- const ssoAuthUrl = new URL('/sso/oauth2/token', ssoBaseUrl).toString();
83
- console.error(`Using SSO auth URL: ${ssoAuthUrl}`);
67
+ const createOAuthClientCredentialsHttpClient = async (environmentUrl, scopes, clientId, clientSecret) => {
68
+ console.error(`🔒 Client-Creds-Flow: Trying to authenticate API Calls to ${environmentUrl} via OAuthClientId ${clientId} with the following scopes: ${scopes.join(', ')}`);
69
+ // Get SSO Base URL
70
+ const ssoBaseURL = await (0, dt_app_1.getSSOUrl)(environmentUrl);
84
71
  // try to request a token, just to verify that everything is set up correctly
85
- const tokenResponse = await requestToken(clientId, clientSecret, ssoAuthUrl, scopes);
72
+ const tokenResponse = await (0, dynatrace_oauth_client_credentials_1.requestTokenForClientCredentials)(clientId, clientSecret, ssoBaseURL, scopes);
86
73
  // in case we didn't get a token, or error / error_description / issueId is set, we throw an error
87
74
  if (!tokenResponse.access_token || tokenResponse.error || tokenResponse.error_description || tokenResponse.issueId) {
88
75
  throw new Error(`Failed to retrieve OAuth token (IssueId: ${tokenResponse.issueId}): ${tokenResponse.error} - ${tokenResponse.error_description}. Note: Your OAuth client is most likely not configured correctly and/or is missing scopes.`);
89
76
  }
90
- console.error(`Successfully retrieved token from SSO!`);
77
+ console.error(`Successfully retrieved token from SSO! Token valid for ${tokenResponse.expires_in}s with scopes: ${tokenResponse.scope}`);
78
+ // now that we have the access token, we can just use a plain bearer token client
79
+ return createBearerTokenHttpClient(environmentUrl, tokenResponse.access_token);
80
+ };
81
+ /** Create an OAuth Client using authorization code flow (interactive authentication)
82
+ * This starts a local HTTP server to handle the OAuth redirect and requires user interaction.
83
+ * Implements token caching (via .dt-mcp/token.json) to avoid repeated OAuth flows.
84
+ * Note: Always requests a complete set of scopes for maximum token reusability. Else the user will end up having to approve multiple requests.
85
+ */
86
+ const createOAuthAuthCodeFlowHttpClient = async (environmentUrl, scopes, clientId) => {
87
+ // Get SSO Base URL
88
+ const ssoBaseURL = await (0, dt_app_1.getSSOUrl)(environmentUrl);
89
+ // Fast Track: Fetch cached token and check if it is still valid
90
+ const cachedToken = token_cache_1.globalTokenCache.getToken(scopes);
91
+ const isValid = token_cache_1.globalTokenCache.isTokenValid(scopes);
92
+ // If we have a valid cached token, we can use it
93
+ if (isValid && cachedToken) {
94
+ const expiresIn = cachedToken.expires_at ? Math.round((cachedToken.expires_at - Date.now()) / 1000) : 'never';
95
+ console.error(`✅ Auth-Code-Flow: Using cached access token (expires in ${expiresIn}s)`);
96
+ // just use the cached token as a bearer token
97
+ return createBearerTokenHttpClient(environmentUrl, cachedToken.access_token);
98
+ }
99
+ // If we have an expired token that can be refreshed, refresh it
100
+ if (cachedToken && cachedToken.refresh_token && !isValid) {
101
+ const expiresIn = cachedToken.expires_at ? Math.round((cachedToken.expires_at - Date.now()) / 1000) : 'never';
102
+ console.error(`🔍 Auth-Code-Flow: Found expired cached token (expires in ${expiresIn}s), attempting refresh...`);
103
+ try {
104
+ console.error(`🔄 Attempting to refresh expired access token...`);
105
+ const tokenResponse = await (0, dynatrace_oauth_auth_code_flow_1.refreshAccessToken)(ssoBaseURL, clientId, cachedToken.refresh_token, scopes);
106
+ if (tokenResponse.access_token && !tokenResponse.error) {
107
+ console.error(`✅ Successfully refreshed access token!`);
108
+ // Update the cache with the new token
109
+ token_cache_1.globalTokenCache.setToken(scopes, tokenResponse);
110
+ // now use the updated token as a bearer token
111
+ return createBearerTokenHttpClient(environmentUrl, tokenResponse.access_token);
112
+ }
113
+ else {
114
+ console.error(`❌ Token refresh failed: ${tokenResponse.error} - ${tokenResponse.error_description}`);
115
+ // Clear the invalid token from cache
116
+ token_cache_1.globalTokenCache.clearToken();
117
+ }
118
+ }
119
+ catch (error) {
120
+ console.error(`❌ Token refresh failed with error: ${error instanceof Error ? error.message : String(error)}`);
121
+ // Clear the invalid token from cache
122
+ token_cache_1.globalTokenCache.clearToken();
123
+ }
124
+ }
125
+ // If we get here, we are currently not authenticated, and need to perform a full OAuth Authorization Code Flow
126
+ console.error(`🚀 Auth-Code-Flow: No valid cached token found, initiating OAuth Authorization Code Flow...`);
127
+ console.error(`Using SSO base URL ${ssoBaseURL}`);
128
+ // Randomly select a port for the OAuth redirect URL (e.g., 5344)
129
+ const port = (0, utils_1.getRandomPort)();
130
+ // Perform the OAuth authorization code flow with all scopes
131
+ const tokenResponse = await (0, dynatrace_oauth_auth_code_flow_1.performOAuthAuthorizationCodeFlow)(ssoBaseURL, {
132
+ clientId,
133
+ // redirectUri will be used as a redirect/callback from the authorization code flow
134
+ redirectUri: `http://localhost:${port}/auth/login`,
135
+ scopes: scopes, // Request all scopes upfront
136
+ }, port);
137
+ // Check if we got a valid token
138
+ if (!tokenResponse.access_token || tokenResponse.error || tokenResponse.error_description || tokenResponse.issueId) {
139
+ throw new Error(`Failed to retrieve OAuth token via authorization code flow (IssueId: ${tokenResponse.issueId}): ${tokenResponse.error} - ${tokenResponse.error_description}`);
140
+ }
141
+ // Cache the new token with all scopes
142
+ token_cache_1.globalTokenCache.setToken(scopes, tokenResponse);
143
+ console.error(`✅ Successfully retrieved token from SSO! Token cached for future use with scopes: ${scopes.join(', ')}`);
144
+ // now that we have the access token, we can just use a plain bearer token client
91
145
  return createBearerTokenHttpClient(environmentUrl, tokenResponse.access_token);
92
146
  };
@@ -3,9 +3,13 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  const dynatrace_clients_1 = require("./dynatrace-clients");
4
4
  const http_client_1 = require("@dynatrace-sdk/http-client");
5
5
  const dt_app_1 = require("dt-app");
6
+ const dynatrace_oauth_auth_code_flow_1 = require("./dynatrace-oauth-auth-code-flow");
7
+ const token_cache_1 = require("./token-cache");
6
8
  // Mock external dependencies
7
9
  jest.mock('@dynatrace-sdk/http-client');
8
10
  jest.mock('dt-app');
11
+ jest.mock('./dynatrace-oauth-auth-code-flow');
12
+ jest.mock('./token-cache');
9
13
  jest.mock('../../package.json', () => ({
10
14
  version: '1.0.0-test',
11
15
  }));
@@ -13,12 +17,21 @@ jest.mock('../../package.json', () => ({
13
17
  global.fetch = jest.fn();
14
18
  const mockPlatformHttpClient = http_client_1.PlatformHttpClient;
15
19
  const mockGetSSOUrl = dt_app_1.getSSOUrl;
20
+ const mockPerformOAuthAuthorizationCodeFlow = dynatrace_oauth_auth_code_flow_1.performOAuthAuthorizationCodeFlow;
21
+ const mockGlobalTokenCache = token_cache_1.globalTokenCache;
16
22
  const mockFetch = global.fetch;
17
23
  describe('dynatrace-clients', () => {
18
24
  beforeEach(() => {
19
25
  jest.clearAllMocks();
20
26
  // Reset console.error mock
21
27
  jest.spyOn(console, 'error').mockImplementation(() => { });
28
+ // Mock token cache methods
29
+ mockGlobalTokenCache.getToken.mockReturnValue(null);
30
+ mockGlobalTokenCache.isTokenValid.mockReturnValue(false);
31
+ mockGlobalTokenCache.setToken.mockImplementation(() => { });
32
+ mockGlobalTokenCache.clearToken.mockImplementation(() => { });
33
+ // Mock getSSOUrl
34
+ mockGetSSOUrl.mockResolvedValue('https://sso.dynatrace.com');
22
35
  });
23
36
  afterEach(() => {
24
37
  jest.restoreAllMocks();
@@ -67,12 +80,6 @@ describe('dynatrace-clients', () => {
67
80
  });
68
81
  expect(result).toBeInstanceOf(http_client_1.PlatformHttpClient);
69
82
  });
70
- it('should throw error when clientId, clientSecret and platformToken are missing', async () => {
71
- await expect((0, dynatrace_clients_1.createDtHttpClient)(environmentUrl, scopes, undefined, undefined, undefined)).rejects.toThrow('Failed to create Dynatrace HTTP Client: Please provide either clientId and clientSecret or dtPlatformToken');
72
- });
73
- it('should throw error when environmentUrl is missing', async () => {
74
- await expect((0, dynatrace_clients_1.createDtHttpClient)('', scopes, clientId, clientSecret)).rejects.toThrow('Failed to retrieve environment URL from env "DT_ENVIRONMENT"');
75
- });
76
83
  it('should throw error when token request fails with HTTP error', async () => {
77
84
  mockFetch.mockResolvedValueOnce({
78
85
  ok: false,
@@ -120,7 +127,7 @@ describe('dynatrace-clients', () => {
120
127
  json: async () => mockTokenResponse,
121
128
  });
122
129
  await (0, dynatrace_clients_1.createDtHttpClient)(environmentUrl, scopes, clientId, clientSecret);
123
- expect(console.error).toHaveBeenCalledWith(`Trying to authenticate API Calls to ${environmentUrl} via OAuthClientId ${clientId} with the following scopes: ${scopes.join(', ')}`);
130
+ expect(console.error).toHaveBeenCalledWith(`🔒 Client-Creds-Flow: Trying to authenticate API Calls to ${environmentUrl} via OAuthClientId ${clientId} with the following scopes: ${scopes.join(', ')}`);
124
131
  });
125
132
  });
126
133
  describe('with Bearer token', () => {
@@ -137,11 +144,6 @@ describe('dynatrace-clients', () => {
137
144
  expect(result).toBeInstanceOf(http_client_1.PlatformHttpClient);
138
145
  });
139
146
  });
140
- describe('with no authentication', () => {
141
- it('should throw error when no authentication method is provided', async () => {
142
- await expect((0, dynatrace_clients_1.createDtHttpClient)(environmentUrl, scopes)).rejects.toThrow('Failed to create Dynatrace HTTP Client: Please provide either clientId and clientSecret or dtPlatformToken');
143
- });
144
- });
145
147
  });
146
148
  describe('requestToken function (indirectly tested)', () => {
147
149
  it('should handle fetch errors gracefully', async () => {
@@ -0,0 +1,220 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.createAuthorizationUrl = createAuthorizationUrl;
7
+ exports.exchangeCodeForToken = exchangeCodeForToken;
8
+ exports.refreshAccessToken = refreshAccessToken;
9
+ exports.startOAuthRedirectServer = startOAuthRedirectServer;
10
+ exports.performOAuthAuthorizationCodeFlow = performOAuthAuthorizationCodeFlow;
11
+ const node_crypto_1 = require("node:crypto");
12
+ const node_http_1 = require("node:http");
13
+ const node_url_1 = require("node:url");
14
+ const dynatrace_oauth_base_1 = require("./dynatrace-oauth-base");
15
+ const utils_1 = require("./utils");
16
+ const open_1 = __importDefault(require("open"));
17
+ /**
18
+ * Generates PKCE code verifier and challenge according to RFC 7636
19
+ * Uses 46 bytes for code verifier as recommended by Auth0/OAuth best practices
20
+ */
21
+ function generatePKCEChallenge() {
22
+ const codeVerifier = (0, utils_1.base64URLEncode)((0, node_crypto_1.randomBytes)(46));
23
+ const codeChallenge = (0, utils_1.base64URLEncode)((0, node_crypto_1.createHash)('sha256').update(codeVerifier).digest());
24
+ return { codeVerifier, codeChallenge };
25
+ }
26
+ /**
27
+ * Constructs the OAuth authorization URL with PKCE
28
+ */
29
+ function createAuthorizationUrl(ssoBaseURL, config) {
30
+ const state = (0, utils_1.generateRandomState)();
31
+ const { codeVerifier, codeChallenge } = generatePKCEChallenge();
32
+ const authUrl = new node_url_1.URL('/oauth2/authorize', ssoBaseURL);
33
+ // Build query parameters manually to control encoding and exact order
34
+ // Order parameters to match working OAuth implementation:
35
+ // client_id → redirect_uri → state → response_type → code_challenge_method → code_challenge → scope
36
+ const queryParts = [
37
+ `client_id=${encodeURIComponent(config.clientId)}`,
38
+ `redirect_uri=${encodeURIComponent(config.redirectUri)}`,
39
+ `state=${encodeURIComponent(state)}`,
40
+ `response_type=code`,
41
+ `code_challenge_method=S256`,
42
+ `code_challenge=${encodeURIComponent(codeChallenge)}`,
43
+ `scope=${encodeURIComponent(config.scopes.join(' ')).replace(/%20/g, '%20')}`, // Ensure %20 for spaces
44
+ ];
45
+ const queryString = queryParts.join('&');
46
+ // Manually construct the final URL to ensure exact parameter order and encoding required by some OAuth implementations.
47
+ const finalUrl = `${authUrl.origin}${authUrl.pathname}?${queryString}`;
48
+ return {
49
+ authorizationUrl: finalUrl,
50
+ codeVerifier,
51
+ state,
52
+ };
53
+ }
54
+ /**
55
+ * Exchanges authorization code for access token using PKCE
56
+ */
57
+ async function exchangeCodeForToken(ssoBaseURL, config, code, codeVerifier) {
58
+ return (0, dynatrace_oauth_base_1.requestOAuthToken)(ssoBaseURL, {
59
+ grant_type: 'authorization_code',
60
+ client_id: config.clientId,
61
+ code,
62
+ redirect_uri: config.redirectUri,
63
+ code_verifier: codeVerifier,
64
+ });
65
+ }
66
+ /**
67
+ * Refreshes an access token using a refresh token
68
+ */
69
+ async function refreshAccessToken(ssoBaseURL, clientId, refreshToken, scopes) {
70
+ const tokenResponse = await (0, dynatrace_oauth_base_1.requestOAuthToken)(ssoBaseURL, {
71
+ grant_type: 'refresh_token',
72
+ client_id: clientId,
73
+ refresh_token: refreshToken,
74
+ scope: scopes.join(' '),
75
+ });
76
+ // For refresh token, we want to throw an error if the request failed
77
+ // since this is different from other flows where we just return the error response
78
+ if (!tokenResponse.access_token || tokenResponse.error) {
79
+ throw new Error(`Failed to refresh access token: ${tokenResponse.error} - ${tokenResponse.error_description}`);
80
+ }
81
+ return tokenResponse;
82
+ }
83
+ /**
84
+ * Starts a temporary HTTP server to handle the OAuth redirect
85
+ */
86
+ async function startOAuthRedirectServer(port = 5344) {
87
+ const redirectUri = `http://localhost:${port}/auth/login`;
88
+ let resolveAuthCode;
89
+ let rejectAuthCode;
90
+ const authCodePromise = new Promise((resolve, reject) => {
91
+ resolveAuthCode = resolve;
92
+ rejectAuthCode = reject;
93
+ });
94
+ const server = (0, node_http_1.createServer)((req, res) => {
95
+ const url = new node_url_1.URL(req.url || '', `http://localhost:${port}`);
96
+ if (url.pathname === '/auth/login') {
97
+ const code = url.searchParams.get('code');
98
+ const state = url.searchParams.get('state');
99
+ const error = url.searchParams.get('error');
100
+ const errorDescription = url.searchParams.get('error_description');
101
+ if (error) {
102
+ res.writeHead(400, { 'Content-Type': 'text/html' });
103
+ res.end(`
104
+ <!DOCTYPE html>
105
+ <html>
106
+ <head><title>OAuth Error</title></head>
107
+ <body>
108
+ <h1>OAuth Authorization Error</h1>
109
+ <p><strong>Error:</strong> ${error}</p>
110
+ <p><strong>Description:</strong> ${errorDescription || 'Unknown error'}</p>
111
+ <p>You can close this tab and check the console for more information.</p>
112
+ </body>
113
+ </html>
114
+ `);
115
+ rejectAuthCode(new Error(`OAuth error: ${error} - ${errorDescription}`));
116
+ return;
117
+ }
118
+ if (code && state) {
119
+ res.writeHead(200, { 'Content-Type': 'text/html' });
120
+ res.end(`
121
+ <!DOCTYPE html>
122
+ <html>
123
+ <head><title>OAuth Success</title></head>
124
+ <body>
125
+ <h1>Authorization Successful!</h1>
126
+ <p>You have successfully authorized the Dynatrace MCP Server.</p>
127
+ <p>You can close this tab and return to your terminal.</p>
128
+ <script>
129
+ // Auto-close after 3 seconds
130
+ setTimeout(() => window.close(), 3000);
131
+ </script>
132
+ </body>
133
+ </html>
134
+ `);
135
+ resolveAuthCode({ code, state });
136
+ }
137
+ else {
138
+ res.writeHead(400, { 'Content-Type': 'text/html' });
139
+ res.end(`
140
+ <!DOCTYPE html>
141
+ <html>
142
+ <head><title>Invalid Request</title></head>
143
+ <body>
144
+ <h1>Invalid OAuth Callback</h1>
145
+ <p>The authorization code or state parameter is missing.</p>
146
+ <p>You can close this tab and try again.</p>
147
+ </body>
148
+ </html>
149
+ `);
150
+ rejectAuthCode(new Error('Missing authorization code or state parameter'));
151
+ }
152
+ }
153
+ else {
154
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
155
+ res.end('Not Found');
156
+ }
157
+ });
158
+ return new Promise((resolve, reject) => {
159
+ server.listen(port, 'localhost', () => {
160
+ console.error(`🌐 OAuth redirect server listening on ${redirectUri}`);
161
+ resolve({
162
+ server,
163
+ redirectUri,
164
+ waitForAuthorizationCode: () => authCodePromise,
165
+ });
166
+ });
167
+ server.on('error', reject);
168
+ });
169
+ }
170
+ /**
171
+ * Performs the complete OAuth authorization code flow
172
+ */
173
+ async function performOAuthAuthorizationCodeFlow(ssoBaseURL, config, serverPort = 5344) {
174
+ console.error('🚀 Starting OAuth Authorization Code Flow with local redirect/callback...');
175
+ // Start the redirect server
176
+ const { server, redirectUri, waitForAuthorizationCode } = await startOAuthRedirectServer(serverPort);
177
+ try {
178
+ // Update config with the actual redirect URI
179
+ const updatedConfig = { ...config, redirectUri };
180
+ // Create authorization URL
181
+ const { authorizationUrl, codeVerifier, state } = createAuthorizationUrl(ssoBaseURL, updatedConfig);
182
+ // Print a pretty message telling the user to open the URL
183
+ console.error('\n' + '='.repeat(60));
184
+ console.error('🔐 OAuth Authorization Required');
185
+ console.error('='.repeat(60));
186
+ console.error('');
187
+ // Open the authorization URL in the default browser
188
+ console.error('Trying to open the authorization URL in your default browser...');
189
+ try {
190
+ (0, open_1.default)(authorizationUrl);
191
+ }
192
+ catch (error) {
193
+ console.error('Failed to open browser automatically. Please click on the following URL to authorize the application:', error.message);
194
+ }
195
+ console.error('');
196
+ console.error('👉 ' + authorizationUrl);
197
+ console.error('');
198
+ console.error('After authorization, you will be redirected back and the server will continue automatically.');
199
+ console.error('');
200
+ console.error('='.repeat(60) + '\n');
201
+ // Wait for the authorization code
202
+ const { code, state: receivedState } = await waitForAuthorizationCode();
203
+ // Validate state parameter
204
+ if (receivedState !== state) {
205
+ throw new Error('OAuth state parameter mismatch - possible CSRF attack');
206
+ }
207
+ console.error('✅ Authorization code received! Exchanging for access token...');
208
+ // Exchange code for token
209
+ const tokenResponse = await exchangeCodeForToken(ssoBaseURL, updatedConfig, code, codeVerifier);
210
+ if (!tokenResponse.access_token || tokenResponse.error) {
211
+ throw new Error(`Failed to exchange code for token: ${tokenResponse.error} - ${tokenResponse.error_description}`);
212
+ }
213
+ console.error('🎉 Successfully obtained access token via OAuth Authorization Code Flow!');
214
+ return tokenResponse;
215
+ }
216
+ finally {
217
+ // Clean up the server
218
+ server.close();
219
+ }
220
+ }
@@ -0,0 +1,50 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const crypto_1 = require("crypto");
4
+ const dynatrace_oauth_auth_code_flow_1 = require("./dynatrace-oauth-auth-code-flow");
5
+ describe('OAuth Authorization Code Flow', () => {
6
+ const mockConfig = {
7
+ clientId: 'dt0s08.mocked-client',
8
+ redirectUri: 'http://localhost:5343/auth/login',
9
+ scopes: ['app-engine:apps:run', 'app-engine:functions:run', 'storage:logs:read'], // Basic Example scopes
10
+ };
11
+ test('createAuthorizationUrl generates valid URL with PKCE', () => {
12
+ const result = (0, dynatrace_oauth_auth_code_flow_1.createAuthorizationUrl)('https://sso.dynatrace.com', mockConfig);
13
+ // URL needs to match sso.dynatrace.com/oauth2/authorize
14
+ expect(result.authorizationUrl).toMatch(/^https:\/\/sso\.dynatrace\.com\/oauth2\/authorize\?/);
15
+ expect(result.codeVerifier).toMatch(/^[A-Za-z0-9_-]{62}$/); // Base64URL without padding (46 bytes = ~62 chars)
16
+ expect(result.state).toMatch(/^[a-f0-9]{40}$/); // Hex string (20 bytes = 40 hex chars)
17
+ // Parse the URL and verify query parameters
18
+ const url = new URL(result.authorizationUrl);
19
+ expect(url.searchParams.get('response_type')).toBe('code');
20
+ expect(url.searchParams.get('client_id')).toBe('dt0s08.mocked-client');
21
+ expect(url.searchParams.get('redirect_uri')).toBe('http://localhost:5343/auth/login');
22
+ expect(url.searchParams.get('scope')).toBe('app-engine:apps:run app-engine:functions:run storage:logs:read');
23
+ expect(url.searchParams.get('code_challenge_method')).toBe('S256');
24
+ expect(url.searchParams.get('code_challenge')).toMatch(/^[A-Za-z0-9_-]{43}$/); // SHA256 base64url = 43 chars
25
+ expect(url.searchParams.get('state')).toBe(result.state);
26
+ });
27
+ test('createAuthorizationUrl encodes scopes with %20 for spaces instead of +', () => {
28
+ const result = (0, dynatrace_oauth_auth_code_flow_1.createAuthorizationUrl)('https://sso.dynatrace.com', mockConfig);
29
+ // Check that the raw URL string contains %20 for spaces, not +
30
+ expect(result.authorizationUrl).toMatch(/scope=app-engine%3Aapps%3Arun%20app-engine%3Afunctions%3Arun%20storage%3Alogs%3Aread/);
31
+ // Verify that + is not used for space encoding in scopes
32
+ expect(result.authorizationUrl).not.toMatch(/scope=.*\+.*(?=&|$)/);
33
+ // Verify that colons are properly encoded as %3A
34
+ expect(result.authorizationUrl).toMatch(/app-engine%3Aapps%3Arun/);
35
+ expect(result.authorizationUrl).toMatch(/app-engine%3Afunctions%3Arun/);
36
+ expect(result.authorizationUrl).toMatch(/storage%3Alogs%3Aread/);
37
+ // Double-check by parsing the URL and verifying the decoded scope
38
+ const url = new URL(result.authorizationUrl);
39
+ expect(url.searchParams.get('scope')).toBe('app-engine:apps:run app-engine:functions:run storage:logs:read');
40
+ });
41
+ test('startOAuthRedirectServer returns server configuration', async () => {
42
+ const port = ((0, crypto_1.randomBytes)(2).readUInt16BE(0) % 10000) + 5000; // Random port between 5000-5999
43
+ const result = await (0, dynatrace_oauth_auth_code_flow_1.startOAuthRedirectServer)(port);
44
+ expect(result.redirectUri).toBe(`http://localhost:${port}/auth/login`);
45
+ expect(result.server).toBeDefined();
46
+ expect(result.waitForAuthorizationCode).toBeDefined();
47
+ // Clean up
48
+ result.server.close();
49
+ });
50
+ });
@@ -0,0 +1,28 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.requestOAuthToken = void 0;
4
+ /**
5
+ * Generic OAuth token request function that can handle different grant types
6
+ * @param ssoBaseURL - SSO Base URL (e.g., sso.dynatrace.com)
7
+ * @param params - OAuth parameters for the specific grant type (client_credentials, authorization_code, or refresh_token)
8
+ * @returns Response of the OAuth Endpoint
9
+ */
10
+ const requestOAuthToken = async (ssoBaseURL, params) => {
11
+ const tokenUrl = new URL('/sso/oauth2/token', ssoBaseURL).toString();
12
+ const res = await fetch(tokenUrl, {
13
+ method: 'POST',
14
+ headers: {
15
+ 'Content-Type': 'application/x-www-form-urlencoded',
16
+ },
17
+ body: new URLSearchParams(params),
18
+ });
19
+ // check if the response was okay (HTTP 2xx) or not (HTTP 4xx or 5xx)
20
+ if (!res.ok) {
21
+ // log the error
22
+ console.error(`Failed to fetch token: ${res.status} ${res.statusText}`);
23
+ // Note: Do not throw here, as we want to return the error response from the OAuth endpoint
24
+ }
25
+ // and return the JSON result, as it contains additional information
26
+ return await res.json();
27
+ };
28
+ exports.requestOAuthToken = requestOAuthToken;
@@ -0,0 +1,21 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.requestTokenForClientCredentials = void 0;
4
+ const dynatrace_oauth_base_1 = require("./dynatrace-oauth-base");
5
+ /**
6
+ * Uses the provided oauth Client ID and Secret and requests a token via client-credentials flow
7
+ * @param clientId - OAuth Client ID for Dynatrace
8
+ * @param clientSecret - OAuth Client Secret for Dynatrace
9
+ * @param ssoBaseURL - SSO Base URL (e.g., sso.dynatrace.com)
10
+ * @param scopes - List of requested scopes
11
+ * @returns Response of the OAuth Endpoint (which, in the best case includes a token)
12
+ */
13
+ const requestTokenForClientCredentials = async (clientId, clientSecret, ssoBaseURL, scopes) => {
14
+ return (0, dynatrace_oauth_base_1.requestOAuthToken)(ssoBaseURL, {
15
+ grant_type: 'client_credentials',
16
+ client_id: clientId,
17
+ client_secret: clientSecret,
18
+ scope: scopes.join(' '),
19
+ });
20
+ };
21
+ exports.requestTokenForClientCredentials = requestTokenForClientCredentials;
@@ -0,0 +1,149 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.globalTokenCache = exports.FileTokenCache = void 0;
37
+ const fs = __importStar(require("fs"));
38
+ const path = __importStar(require("path"));
39
+ /**
40
+ * File-based token cache implementation that persists tokens to disk
41
+ * Stores tokens in .dt-mcp/token.json for persistence across dynatrace-mcp-server restarts
42
+ */
43
+ class FileTokenCache {
44
+ tokenFilePath;
45
+ token = null;
46
+ constructor() {
47
+ // Create .dt-mcp directory in the current working directory
48
+ const tokenDir = path.join(process.cwd(), '.dt-mcp');
49
+ this.tokenFilePath = path.join(tokenDir, 'token.json');
50
+ // Ensure the directory exists
51
+ if (!fs.existsSync(tokenDir)) {
52
+ fs.mkdirSync(tokenDir, { recursive: true });
53
+ }
54
+ this.loadToken();
55
+ }
56
+ /**
57
+ * Loads the token from the file system
58
+ */
59
+ loadToken() {
60
+ try {
61
+ if (fs.existsSync(this.tokenFilePath)) {
62
+ const tokenData = fs.readFileSync(this.tokenFilePath, 'utf8');
63
+ this.token = JSON.parse(tokenData);
64
+ console.error(`🔍 Loaded token from file: ${this.tokenFilePath}`);
65
+ }
66
+ else {
67
+ console.error(`🔍 No token file found at: ${this.tokenFilePath}`);
68
+ this.token = null;
69
+ }
70
+ }
71
+ catch (error) {
72
+ console.error(`❌ Failed to load token from file: ${error}`);
73
+ this.token = null;
74
+ }
75
+ }
76
+ /**
77
+ * Saves the token to the file system
78
+ */
79
+ saveToken() {
80
+ try {
81
+ if (this.token) {
82
+ fs.writeFileSync(this.tokenFilePath, JSON.stringify(this.token, null, 2), 'utf8');
83
+ console.error(`✅ Saved token to file: ${this.tokenFilePath}`);
84
+ }
85
+ else {
86
+ // Remove the file if no token exists
87
+ if (fs.existsSync(this.tokenFilePath)) {
88
+ fs.unlinkSync(this.tokenFilePath);
89
+ console.error(`🗑️ Removed token file: ${this.tokenFilePath}`);
90
+ }
91
+ }
92
+ }
93
+ catch (error) {
94
+ console.error(`❌ Failed to save token to file: ${error}`);
95
+ }
96
+ }
97
+ /**
98
+ * Retrieves the cached token (ignores scopes since we use a global token)
99
+ */
100
+ getToken(scopes) {
101
+ // We ignore the scopes parameter since we use a single token with all scopes
102
+ return this.token;
103
+ }
104
+ /**
105
+ * Stores the global token in the cache and persists it to file
106
+ */
107
+ setToken(scopes, token) {
108
+ // We ignore the scopes parameter since we use a single token with all scopes
109
+ this.token = {
110
+ access_token: token.access_token,
111
+ refresh_token: token.refresh_token,
112
+ expires_at: token.expires_in ? Date.now() + token.expires_in * 1000 : undefined,
113
+ scopes: [...scopes], // Store the actual scopes that were granted
114
+ };
115
+ this.saveToken();
116
+ }
117
+ /**
118
+ * Removes the cached token and deletes the file
119
+ */
120
+ clearToken(scopes) {
121
+ // We ignore the scopes parameter since we use a single global token
122
+ this.token = null;
123
+ this.saveToken();
124
+ }
125
+ /**
126
+ * Checks if the token exists and is still valid (not expired)
127
+ */
128
+ isTokenValid(scopes) {
129
+ // We ignore the scopes parameter since we use a single token with all scopes
130
+ if (!this.token) {
131
+ console.error(`🔍 Token validation: No token in cache`);
132
+ return false;
133
+ }
134
+ // If no expiration time is set, assume token is valid
135
+ if (!this.token.expires_at) {
136
+ console.error(`🔍 Token validation: Token has no expiration, assuming valid`);
137
+ return true;
138
+ }
139
+ // Add a 30-second buffer to avoid using tokens that are about to expire
140
+ const bufferMs = 30 * 1000; // 30 seconds
141
+ const now = Date.now();
142
+ const expiresAt = this.token.expires_at;
143
+ const isValid = now + bufferMs < expiresAt;
144
+ return isValid;
145
+ }
146
+ }
147
+ exports.FileTokenCache = FileTokenCache;
148
+ // Global token cache instance - uses file-based persistence
149
+ exports.globalTokenCache = new FileTokenCache();
@@ -0,0 +1,26 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.getRandomPort = exports.generateRandomState = exports.base64URLEncode = void 0;
4
+ const node_crypto_1 = require("node:crypto");
5
+ /**
6
+ * Base64URL encoding according to RFC 7636
7
+ */
8
+ const base64URLEncode = (buffer) => {
9
+ return buffer.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
10
+ };
11
+ exports.base64URLEncode = base64URLEncode;
12
+ /**
13
+ * Generates a cryptographically secure random string for OAuth state parameter
14
+ * Uses hex encoding for better compatibility
15
+ */
16
+ const generateRandomState = () => {
17
+ return (0, node_crypto_1.randomBytes)(20).toString('hex');
18
+ };
19
+ exports.generateRandomState = generateRandomState;
20
+ /**
21
+ * Generates a random port number between min and max (inclusive)
22
+ */
23
+ const getRandomPort = (min = 5344, max = 5349) => {
24
+ return Math.floor(Math.random() * (max - min + 1)) + min;
25
+ };
26
+ exports.getRandomPort = getRandomPort;
@@ -15,9 +15,8 @@ function getDynatraceEnv(env = process.env) {
15
15
  if (!dtEnvironment) {
16
16
  throw new Error('Please set DT_ENVIRONMENT environment variable to your Dynatrace Platform Environment');
17
17
  }
18
- if (!oauthClientId && !oauthClientSecret && !dtPlatformToken) {
19
- throw new Error('Please set either OAUTH_CLIENT_ID and OAUTH_CLIENT_SECRET, or DT_PLATFORM_TOKEN environment variables');
20
- }
18
+ // Allow case where no auth credentials are provided - OAuth auth code flow will be inferred
19
+ // We only require DT_ENVIRONMENT to be set
21
20
  // For dev and hardening stages, set unlimited budget (-1) unless explicitly overridden
22
21
  if (dtEnvironment.includes('apps.dynatracelabs.com') && !env.DT_GRAIL_QUERY_BUDGET_GB) {
23
22
  grailBudgetGB = -1;
@@ -26,14 +26,18 @@ describe('getDynatraceEnv', () => {
26
26
  const result = (0, getDynatraceEnv_1.getDynatraceEnv)(env);
27
27
  expect(result.slackConnectionId).toBe('fake-slack-connection-id');
28
28
  });
29
- it('throws if environment variables for auth credentials are missing', () => {
29
+ it('allows missing auth credentials (OAuth auth code flow will be inferred)', () => {
30
30
  const env = {
31
31
  ...baseEnv,
32
32
  OAUTH_CLIENT_ID: undefined,
33
33
  OAUTH_CLIENT_SECRET: undefined,
34
34
  DT_PLATFORM_TOKEN: undefined,
35
35
  };
36
- expect(() => (0, getDynatraceEnv_1.getDynatraceEnv)(env)).toThrow(/OAUTH_CLIENT_ID/);
36
+ expect(() => (0, getDynatraceEnv_1.getDynatraceEnv)(env)).not.toThrow();
37
+ const result = (0, getDynatraceEnv_1.getDynatraceEnv)(env);
38
+ expect(result.oauthClientId).toBeUndefined();
39
+ expect(result.oauthClientSecret).toBeUndefined();
40
+ expect(result.dtPlatformToken).toBeUndefined();
37
41
  });
38
42
  it('throws if DT_ENVIRONMENT is missing', () => {
39
43
  const env = { ...baseEnv, DT_ENVIRONMENT: undefined };
package/dist/index.js CHANGED
@@ -41,16 +41,45 @@ else {
41
41
  // Successfully loaded .env file
42
42
  console.error(`.env file loaded successfully - loaded ${dotEnvOutput.parsed ? Object.keys(dotEnvOutput.parsed).length : 0} environment variables: ${Object.keys(dotEnvOutput.parsed || {}).join(', ')}`);
43
43
  }
44
+ const DT_MCP_AUTH_CODE_FLOW_OAUTH_CLIENT_ID = 'dt0s08.dt-app-local'; // ToDo: Register our own oauth client
44
45
  let scopesBase = [
45
46
  'app-engine:apps:run', // needed for environmentInformationClient
46
47
  'app-engine:functions:run', // needed for environmentInformationClient
47
48
  ];
49
+ // All scopes needed by the MCP server tools
50
+ // Requesting all scopes upfront allows us to reuse a single token for all operations
51
+ const allRequiredScopes = scopesBase.concat([
52
+ // Storage (Grail) scopes
53
+ 'storage:events:read', // Read events from Grail
54
+ 'storage:buckets:read', // Read all system data stored on Grail
55
+ 'storage:security.events:read', // Read Security events from Grail
56
+ 'storage:entities:read', // Read Entities from Grail
57
+ 'storage:logs:read', // Read logs for reliability guardian validations
58
+ 'storage:metrics:read', // Read metrics for reliability guardian validations
59
+ 'storage:bizevents:read', // Read bizevents for reliability guardian validations
60
+ 'storage:spans:read', // Read spans from Grail
61
+ 'storage:system:read', // Read System Data from Grail
62
+ // Settings and configuration scopes
63
+ 'app-settings:objects:read', // Read app settings objects
64
+ 'settings:objects:read', // Read settings objects
65
+ 'environment-api:entities:read', // Read entities via environment API
66
+ // Davis CoPilot scopes
67
+ 'davis-copilot:nl2dql:execute', // Convert natural language to DQL
68
+ 'davis-copilot:dql2nl:execute', // Convert DQL to natural language
69
+ 'davis-copilot:conversations:execute', // Chat with Davis CoPilot
70
+ // Automation/Workflows scopes
71
+ 'automation:workflows:write', // Create and modify workflows
72
+ 'automation:workflows:read', // Read workflows
73
+ 'automation:workflows:run', // Execute workflows
74
+ // Communication scopes
75
+ 'email:emails:send', // Send emails
76
+ ]);
48
77
  /**
49
78
  * Performs a connection test to the Dynatrace environment.
50
79
  * Throws an error if the connection or authentication fails.
51
80
  */
52
81
  async function testDynatraceConnection(dtEnvironment, oauthClientId, oauthClientSecret, dtPlatformToken) {
53
- const dtClient = await (0, dynatrace_clients_1.createDtHttpClient)(dtEnvironment, scopesBase, oauthClientId, oauthClientSecret, dtPlatformToken);
82
+ const dtClient = await (0, dynatrace_clients_1.createDtHttpClient)(dtEnvironment, oauthClientId && !oauthClientSecret ? allRequiredScopes : scopesBase, oauthClientId, oauthClientSecret, dtPlatformToken);
54
83
  const environmentInformationClient = new client_platform_management_service_1.EnvironmentInformationClient(dtClient);
55
84
  // This call will fail if authentication is incorrect.
56
85
  await environmentInformationClient.getEnvironmentInformation();
@@ -78,7 +107,7 @@ async function retryTestDynatraceConnection(dtEnvironment, oauthClientId, oauthC
78
107
  break;
79
108
  }
80
109
  catch (error) {
81
- console.error(`Error: Could not connect to the Dynatrace environment.`);
110
+ console.error(`Error: Could not connect to the Dynatrace environment at ${dtEnvironment}.`);
82
111
  if ((0, shared_errors_1.isClientRequestError)(error)) {
83
112
  console.error(handleClientRequestError(error));
84
113
  }
@@ -108,9 +137,19 @@ const main = async () => {
108
137
  process.exit(1);
109
138
  }
110
139
  // Unpack environment variables
111
- const { oauthClientId, oauthClientSecret, dtEnvironment, dtPlatformToken, slackConnectionId, grailBudgetGB } = dynatraceEnv;
140
+ let { oauthClientId, oauthClientSecret, dtEnvironment, dtPlatformToken, slackConnectionId, grailBudgetGB } = dynatraceEnv;
141
+ // Infer OAuth auth code flow if no OAuth Client credentials are provided
142
+ // -> configure default OAuth client ID for auth code flow
143
+ if (!oauthClientId && !oauthClientSecret && !dtPlatformToken) {
144
+ console.error('No OAuth credentials or platform token provided - switching to OAuth authorization code flow.');
145
+ oauthClientId = DT_MCP_AUTH_CODE_FLOW_OAUTH_CLIENT_ID; // Default OAuth client ID for auth code flow
146
+ }
112
147
  // Test connection on startup
113
148
  try {
149
+ // Depending on the authentication type, there are multiple pitfalls
150
+ // * For Platform Tokens, we can just try to access "get environment info" and we will know whether it works
151
+ // * For Oauth Client Credentials flow, we can also try to request an access token upfront with limited scopes, and verify whether everything works
152
+ // * for Oauth Auth Code flow, we can only verify whether the client ID is valid and the OAuth verifier call works, but we can't verify whether the user will be able to authenticate successfully
114
153
  await retryTestDynatraceConnection(dtEnvironment, oauthClientId, oauthClientSecret, dtPlatformToken);
115
154
  }
116
155
  catch (err) {
@@ -142,6 +181,14 @@ const main = async () => {
142
181
  elicitation: {},
143
182
  },
144
183
  });
184
+ // Helper function to create HTTP client with current auth settings
185
+ // This is used to provide global scopes for auth code flow
186
+ const createAuthenticatedHttpClient = async (scopes) => {
187
+ // If we use authorization code flow (e.g., oauthClientId is set, but oauthClientSecret is empty), we pass all scopes in.
188
+ // For all other cases, we use allRequiredScopes
189
+ return await (0, dynatrace_clients_1.createDtHttpClient)(dtEnvironment, oauthClientId && !oauthClientSecret ? allRequiredScopes : scopes, // Always use all scopes for maximum reusability
190
+ oauthClientId, oauthClientSecret, dtPlatformToken);
191
+ };
145
192
  // quick abstraction/wrapper to make it easier for tools to reply text instead of JSON
146
193
  const tool = (name, description, paramsSchema, annotations, cb) => {
147
194
  const wrappedCb = async (args) => {
@@ -221,7 +268,7 @@ const main = async () => {
221
268
  readOnlyHint: true,
222
269
  }, async ({}) => {
223
270
  // create an oauth-client
224
- const dtClient = await (0, dynatrace_clients_1.createDtHttpClient)(dtEnvironment, scopesBase, oauthClientId, oauthClientSecret, dtPlatformToken);
271
+ const dtClient = await createAuthenticatedHttpClient(scopesBase);
225
272
  const environmentInformationClient = new client_platform_management_service_1.EnvironmentInformationClient(dtClient);
226
273
  const environmentInfo = await environmentInformationClient.getEnvironmentInformation();
227
274
  let resp = `Environment Information (also referred to as tenant):
@@ -247,7 +294,7 @@ const main = async () => {
247
294
  }, {
248
295
  readOnlyHint: true,
249
296
  }, async ({ riskScore, additionalFilter, maxVulnerabilitiesToDisplay }) => {
250
- const dtClient = await (0, dynatrace_clients_1.createDtHttpClient)(dtEnvironment, scopesBase.concat('storage:events:read', 'storage:buckets:read', 'storage:security.events:read'), oauthClientId, oauthClientSecret, dtPlatformToken);
297
+ const dtClient = await createAuthenticatedHttpClient(scopesBase.concat('storage:events:read', 'storage:buckets:read', 'storage:security.events:read'));
251
298
  const result = await (0, list_vulnerabilities_1.listVulnerabilities)(dtClient, additionalFilter, riskScore);
252
299
  if (!result || result.length === 0) {
253
300
  return 'No vulnerabilities found in the last 30 days';
@@ -290,7 +337,7 @@ const main = async () => {
290
337
  }, {
291
338
  readOnlyHint: true,
292
339
  }, async ({ additionalFilter, maxProblemsToDisplay }) => {
293
- const dtClient = await (0, dynatrace_clients_1.createDtHttpClient)(dtEnvironment, scopesBase.concat('storage:events:read', 'storage:buckets:read'), oauthClientId, oauthClientSecret, dtPlatformToken);
340
+ const dtClient = await createAuthenticatedHttpClient(scopesBase.concat('storage:events:read', 'storage:buckets:read'));
294
341
  // get problems (uses fetch)
295
342
  const result = await (0, list_problems_1.listProblems)(dtClient, additionalFilter);
296
343
  if (result && result.records && result.records.length > 0) {
@@ -334,7 +381,7 @@ const main = async () => {
334
381
  }, {
335
382
  readOnlyHint: true,
336
383
  }, async ({ entityNames, maxEntitiesToDisplay, extendedSearch }) => {
337
- const dtClient = await (0, dynatrace_clients_1.createDtHttpClient)(dtEnvironment, scopesBase.concat('storage:entities:read'), oauthClientId, oauthClientSecret, dtPlatformToken);
384
+ const dtClient = await createAuthenticatedHttpClient(scopesBase.concat('storage:entities:read'));
338
385
  const result = await (0, find_monitored_entity_by_name_1.findMonitoredEntitiesByName)(dtClient, entityNames, extendedSearch);
339
386
  if (result && result.records && result.records.length > 0) {
340
387
  let resp = `Found ${result.records.length} monitored entities! Displaying the first ${maxEntitiesToDisplay} entities:\n`;
@@ -371,7 +418,7 @@ const main = async () => {
371
418
  if (!approved) {
372
419
  return 'Operation cancelled: Human approval was not granted for sending this Slack message.';
373
420
  }
374
- const dtClient = await (0, dynatrace_clients_1.createDtHttpClient)(dtEnvironment, scopesBase.concat('app-settings:objects:read'), oauthClientId, oauthClientSecret, dtPlatformToken);
421
+ const dtClient = await createAuthenticatedHttpClient(scopesBase.concat('app-settings:objects:read'));
375
422
  const response = await (0, send_slack_message_1.sendSlackMessage)(dtClient, slackConnectionId, channel, message);
376
423
  return `Message sent to Slack channel: ${JSON.stringify(response)}`;
377
424
  });
@@ -381,7 +428,7 @@ const main = async () => {
381
428
  readOnlyHint: true,
382
429
  idempotentHint: true, // same input always yields same output
383
430
  }, async ({ dqlStatement }) => {
384
- const dtClient = await (0, dynatrace_clients_1.createDtHttpClient)(dtEnvironment, scopesBase, oauthClientId, oauthClientSecret, dtPlatformToken);
431
+ const dtClient = await createAuthenticatedHttpClient(scopesBase);
385
432
  const response = await (0, execute_dql_1.verifyDqlStatement)(dtClient, dqlStatement);
386
433
  let resp = 'DQL Statement Verification:\n';
387
434
  if (response.notifications && response.notifications.length > 0) {
@@ -413,7 +460,7 @@ const main = async () => {
413
460
  openWorldHint: true,
414
461
  }, async ({ dqlStatement }) => {
415
462
  // Create a HTTP Client that has all storage:*:read scopes
416
- const dtClient = await (0, dynatrace_clients_1.createDtHttpClient)(dtEnvironment, scopesBase.concat('storage:buckets:read', // Read all system data stored on Grail
463
+ const dtClient = await createAuthenticatedHttpClient(scopesBase.concat('storage:buckets:read', // Read all system data stored on Grail
417
464
  'storage:logs:read', // Read logs for reliability guardian validations
418
465
  'storage:metrics:read', // Read metrics for reliability guardian validations
419
466
  'storage:bizevents:read', // Read bizevents for reliability guardian validations
@@ -423,7 +470,7 @@ const main = async () => {
423
470
  'storage:system:read', // Read System Data from Grail
424
471
  'storage:user.events:read', // Read User events from Grail
425
472
  'storage:user.sessions:read', // Read User sessions from Grail
426
- 'storage:security.events:read'), oauthClientId, oauthClientSecret, dtPlatformToken);
473
+ 'storage:security.events:read'));
427
474
  const response = await (0, execute_dql_1.executeDql)(dtClient, { query: dqlStatement }, grailBudgetGB);
428
475
  if (!response) {
429
476
  return 'DQL execution failed or returned no result.';
@@ -474,7 +521,7 @@ const main = async () => {
474
521
  readOnlyHint: true,
475
522
  idempotentHint: true,
476
523
  }, async ({ text }) => {
477
- const dtClient = await (0, dynatrace_clients_1.createDtHttpClient)(dtEnvironment, scopesBase.concat('davis-copilot:nl2dql:execute'), oauthClientId, oauthClientSecret, dtPlatformToken);
524
+ const dtClient = await createAuthenticatedHttpClient(scopesBase.concat('davis-copilot:nl2dql:execute'));
478
525
  // Check if the nl2dql skill is available
479
526
  const isAvailable = await (0, davis_copilot_1.isDavisCopilotSkillAvailable)(dtClient, 'nl2dql');
480
527
  if (!isAvailable) {
@@ -508,7 +555,7 @@ const main = async () => {
508
555
  readOnlyHint: true,
509
556
  idempotentHint: true,
510
557
  }, async ({ dql }) => {
511
- const dtClient = await (0, dynatrace_clients_1.createDtHttpClient)(dtEnvironment, scopesBase.concat('davis-copilot:dql2nl:execute'), oauthClientId, oauthClientSecret, dtPlatformToken);
558
+ const dtClient = await createAuthenticatedHttpClient(scopesBase.concat('davis-copilot:dql2nl:execute'));
512
559
  // Check if the dql2nl skill is available
513
560
  const isAvailable = await (0, davis_copilot_1.isDavisCopilotSkillAvailable)(dtClient, 'dql2nl');
514
561
  if (!isAvailable) {
@@ -538,7 +585,7 @@ const main = async () => {
538
585
  idempotentHint: true,
539
586
  openWorldHint: true, // web-search like characteristics
540
587
  }, async ({ text, context, instruction }) => {
541
- const dtClient = await (0, dynatrace_clients_1.createDtHttpClient)(dtEnvironment, scopesBase.concat('davis-copilot:conversations:execute'), oauthClientId, oauthClientSecret, dtPlatformToken);
588
+ const dtClient = await createAuthenticatedHttpClient(scopesBase.concat('davis-copilot:conversations:execute'));
542
589
  // Check if the conversation skill is available
543
590
  const isAvailable = await (0, davis_copilot_1.isDavisCopilotSkillAvailable)(dtClient, 'conversation');
544
591
  if (!isAvailable) {
@@ -601,7 +648,7 @@ const main = async () => {
601
648
  if (!approved) {
602
649
  return 'Operation cancelled: Human approval was not granted for creating this workflow.';
603
650
  }
604
- const dtClient = await (0, dynatrace_clients_1.createDtHttpClient)(dtEnvironment, scopesBase.concat('automation:workflows:write', 'automation:workflows:read', 'automation:workflows:run'), oauthClientId, oauthClientSecret, dtPlatformToken);
651
+ const dtClient = await createAuthenticatedHttpClient(scopesBase.concat('automation:workflows:write', 'automation:workflows:read', 'automation:workflows:run'));
605
652
  const response = await (0, create_workflow_for_problem_notification_1.createWorkflowForProblemNotification)(dtClient, teamName, channel, problemType, isPrivate);
606
653
  let resp = `Workflow Created: ${response?.id} with name ${response?.title}.\nYou can access the Workflow via the following link: ${dtEnvironment}/ui/apps/dynatrace.automations/workflows/${response?.id}.\nTell the user to inspect the Workflow by visiting the link.\n`;
607
654
  if (response.type == 'SIMPLE') {
@@ -627,7 +674,7 @@ const main = async () => {
627
674
  if (!approved) {
628
675
  return 'Operation cancelled: Human approval was not granted for making this workflow public.';
629
676
  }
630
- const dtClient = await (0, dynatrace_clients_1.createDtHttpClient)(dtEnvironment, scopesBase.concat('automation:workflows:write', 'automation:workflows:read', 'automation:workflows:run'), oauthClientId, oauthClientSecret, dtPlatformToken);
677
+ const dtClient = await createAuthenticatedHttpClient(scopesBase.concat('automation:workflows:write', 'automation:workflows:read', 'automation:workflows:run'));
631
678
  const response = await (0, update_workflow_1.updateWorkflow)(dtClient, workflowId, {
632
679
  isPrivate: false,
633
680
  });
@@ -664,7 +711,7 @@ const main = async () => {
664
711
  }, {
665
712
  readOnlyHint: true,
666
713
  }, async ({ clusterId, kubernetesEntityId, eventType, maxEventsToDisplay }) => {
667
- const dtClient = await (0, dynatrace_clients_1.createDtHttpClient)(dtEnvironment, scopesBase.concat('storage:events:read'), oauthClientId, oauthClientSecret, dtPlatformToken);
714
+ const dtClient = await createAuthenticatedHttpClient(scopesBase.concat('storage:events:read'));
668
715
  const result = await (0, get_events_for_cluster_1.getEventsForCluster)(dtClient, clusterId, kubernetesEntityId, eventType);
669
716
  if (result && result.records && result.records.length > 0) {
670
717
  let resp = `Found ${result.records.length} events! Displaying the top ${maxEventsToDisplay} events:\n`;
@@ -687,7 +734,7 @@ const main = async () => {
687
734
  }, {
688
735
  readOnlyHint: true,
689
736
  }, async ({ entityIds }) => {
690
- const dtClient = await (0, dynatrace_clients_1.createDtHttpClient)(dtEnvironment, scopesBase.concat('environment-api:entities:read', 'settings:objects:read'), oauthClientId, oauthClientSecret, dtPlatformToken);
737
+ const dtClient = await createAuthenticatedHttpClient(scopesBase.concat('environment-api:entities:read', 'settings:objects:read'));
691
738
  console.error(`Fetching ownership for ${entityIds}`);
692
739
  const ownershipInformation = await (0, get_ownership_information_1.getOwnershipInformation)(dtClient, entityIds);
693
740
  console.error(`Done!`);
@@ -741,7 +788,7 @@ You can now execute new Grail queries (DQL, etc.) again. If this happens more of
741
788
  if (!approved) {
742
789
  return 'Operation cancelled: Human approval was not granted for sending this email.';
743
790
  }
744
- const dtClient = await (0, dynatrace_clients_1.createDtHttpClient)(dtEnvironment, scopesBase.concat('email:emails:send'), oauthClientId, oauthClientSecret, dtPlatformToken);
791
+ const dtClient = await createAuthenticatedHttpClient(scopesBase.concat('email:emails:send'));
745
792
  const emailRequest = {
746
793
  toRecipients: { emailAddresses: toRecipients },
747
794
  ...(ccRecipients && { ccRecipients: { emailAddresses: ccRecipients } }),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dynatrace-oss/dynatrace-mcp-server",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "mcpName": "io.github.dynatrace-oss/Dynatrace-mcp",
5
5
  "description": "Model Context Protocol (MCP) server for Dynatrace",
6
6
  "keywords": [
@@ -57,6 +57,7 @@
57
57
  "commander": "^14.0.0",
58
58
  "dotenv": "^17.2.1",
59
59
  "dt-app": "^0.148.1",
60
+ "open": "^8.4.2",
60
61
  "zod-to-json-schema": "^3.24.5"
61
62
  },
62
63
  "devDependencies": {