@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 +10 -111
- package/dist/authentication/dynatrace-clients.js +109 -55
- package/dist/authentication/dynatrace-clients.test.js +14 -12
- package/dist/authentication/dynatrace-oauth-auth-code-flow.js +220 -0
- package/dist/authentication/dynatrace-oauth-auth-code-flow.test.js +50 -0
- package/dist/authentication/dynatrace-oauth-base.js +28 -0
- package/dist/authentication/dynatrace-oauth-client-credentials.js +21 -0
- package/dist/authentication/token-cache.js +149 -0
- package/dist/authentication/utils.js +26 -0
- package/dist/getDynatraceEnv.js +2 -3
- package/dist/getDynatraceEnv.test.js +6 -2
- package/dist/index.js +66 -19
- package/package.json +2 -1
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
|
|
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
|
|
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
|
-
|
|
385
|
-
|
|
386
|
-
- `
|
|
387
|
-
- `
|
|
388
|
-
- `
|
|
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
|
-
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
|
30
|
+
return createPlatformTokenHttpClient(environmentUrl, dtPlatformToken);
|
|
53
31
|
}
|
|
54
|
-
|
|
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
|
-
/**
|
|
58
|
-
|
|
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 ${
|
|
50
|
+
'Authorization': `Bearer ${bearerToken}`,
|
|
63
51
|
'User-Agent': (0, user_agent_1.getUserAgent)(),
|
|
64
52
|
},
|
|
65
53
|
});
|
|
66
54
|
};
|
|
67
|
-
/**
|
|
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
|
|
71
|
-
|
|
72
|
-
|
|
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
|
|
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(
|
|
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;
|
package/dist/getDynatraceEnv.js
CHANGED
|
@@ -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
|
-
|
|
19
|
-
|
|
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('
|
|
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(
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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')
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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.
|
|
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": {
|