@dynatrace-oss/dynatrace-mcp-server 0.4.0 → 0.5.0-rc.2
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 +73 -33
- package/dist/authentication/dynatrace-clients.js +30 -27
- package/dist/authentication/dynatrace-clients.test.js +189 -0
- package/dist/capabilities/davis-copilot.js +63 -0
- package/dist/capabilities/execute-dql.js +10 -6
- package/dist/capabilities/find-monitored-entity-by-name.js +1 -1
- package/dist/capabilities/get-events-for-cluster.js +1 -1
- package/dist/capabilities/get-logs-for-entity.js +1 -1
- package/dist/capabilities/list-problems.js +23 -10
- package/dist/getDynatraceEnv.js +8 -4
- package/dist/getDynatraceEnv.test.js +10 -7
- package/dist/index.js +138 -46
- package/package.json +3 -1
package/README.md
CHANGED
|
@@ -7,10 +7,10 @@ Bring real-time observability data directly into your development workflow.
|
|
|
7
7
|
|
|
8
8
|
## Use cases
|
|
9
9
|
|
|
10
|
-
- Real-time observability
|
|
11
|
-
- Fix issues
|
|
12
|
-
-
|
|
13
|
-
- Natural language
|
|
10
|
+
- **Real-time observability** - Fetch production-level data for early detection and proactive monitoring
|
|
11
|
+
- **Contextual debugging** - Fix issues with full context from monitored exceptions, logs, and anomalies
|
|
12
|
+
- **Security insights** - Get detailed vulnerability analysis and security problem tracking
|
|
13
|
+
- **Natural language queries** - Use AI-powered DQL generation and explanation
|
|
14
14
|
|
|
15
15
|
## Capabilities
|
|
16
16
|
|
|
@@ -22,9 +22,16 @@ Bring real-time observability data directly into your development workflow.
|
|
|
22
22
|
- Get more information about a monitored entity
|
|
23
23
|
- Get Ownership of an entity
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
### AI-Powered Assistance (Preview)
|
|
26
|
+
|
|
27
|
+
- **Natural Language to DQL** - Convert plain English queries to Dynatrace Query Language
|
|
28
|
+
- **DQL Explanation** - Get plain English explanations of complex DQL queries
|
|
29
|
+
- **AI Chat Assistant** - Get contextual help and guidance for Dynatrace questions
|
|
30
|
+
- **Feedback System** - Provide feedback to improve AI responses over time
|
|
26
31
|
|
|
27
|
-
**
|
|
32
|
+
> **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).
|
|
33
|
+
|
|
34
|
+
## Quickstart
|
|
28
35
|
|
|
29
36
|
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`.
|
|
30
37
|
|
|
@@ -106,45 +113,76 @@ This configuration should be stored in `<your-repo>/.amazonq/mcp.json`.
|
|
|
106
113
|
|
|
107
114
|
## Environment Variables
|
|
108
115
|
|
|
109
|
-
|
|
110
|
-
[creating an Oauth Client in Dynatrace](https://docs.dynatrace.com/docs/manage/identity-access-management/access-tokens-and-oauth-clients/oauth-clients),
|
|
111
|
-
and set up the following environment variables in order for this MCP to work:
|
|
116
|
+
You can set up authentication via **OAuth Client** or **Platform Tokens** (v0.5.0 and newer) via the following environment variables:
|
|
112
117
|
|
|
113
118
|
- `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`)
|
|
114
119
|
- `OAUTH_CLIENT_ID` (string, e.g., `dt0s02.SAMPLE`) - Dynatrace OAuth Client ID
|
|
115
120
|
- `OAUTH_CLIENT_SECRET` (string, e.g., `dt0s02.SAMPLE.abcd1234`) - Dynatrace OAuth Client Secret
|
|
116
|
-
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
- `environment-api:entities:read` - read monitored entities
|
|
122
|
-
- `environment-api:problems:read` - get problems
|
|
123
|
-
- `environment-api:metrics:read` - read metrics
|
|
124
|
-
- `environment-api:slo:read` - read SLOs
|
|
125
|
-
- `storage:buckets:read` - Read all system data stored on Grail
|
|
126
|
-
- `storage:logs:read` - Read logs for reliability guardian validations
|
|
127
|
-
- `storage:metrics:read` - Read metrics for reliability guardian validations
|
|
128
|
-
- `storage:bizevents:read` - Read bizevents for reliability guardian validations
|
|
129
|
-
- `storage:spans:read` - Read spans from Grail
|
|
130
|
-
- `storage:entities:read` - Read Entities from Grail
|
|
131
|
-
- `storage:events:read` - Read Events from Grail
|
|
132
|
-
- `storage:system:read` - Read System Data from Grail
|
|
133
|
-
- `storage:user.events:read` - Read User events from Grail
|
|
134
|
-
- `storage:user.sessions:read` - Read User sessions from Grail
|
|
135
|
-
- `settings:objects:read` - needed for reading ownership information and Guardians (SRG) from settings
|
|
136
|
-
|
|
137
|
-
**Note**: Please ensure that `settings:objects:read` is used, and _not_ the similarly named scope `app-settings:objects:read`.
|
|
121
|
+
- With v0.5.0 and newer: `DT_PLATFORM_TOKEN` (string, e.g., `dt0s16.SAMPLE.abcd1234`) - Dynatrace Platform Token (limited support, as not all scopes are available; see below)
|
|
122
|
+
|
|
123
|
+
For more information, please have a look at the documentation about
|
|
124
|
+
[creating an Oauth Client in Dynatrace](https://docs.dynatrace.com/docs/manage/identity-access-management/access-tokens-and-oauth-clients/oauth-clients), as well as
|
|
125
|
+
[creating a Platform Token in Dynatrace](https://docs.dynatrace.com/docs/manage/identity-access-management/access-tokens-and-oauth-clients/platform-tokens).
|
|
138
126
|
|
|
139
127
|
In addition, depending on the features you use, the following variables can be configured:
|
|
140
128
|
|
|
141
129
|
- `SLACK_CONNECTION_ID` (string) - connection ID of a [Slack Connection](https://docs.dynatrace.com/docs/analyze-explore-automate/workflows/actions/slack)
|
|
142
130
|
|
|
131
|
+
### Scopes for Authentication
|
|
132
|
+
|
|
133
|
+
Depending on the features you are using, the following scopes are needed:
|
|
134
|
+
|
|
135
|
+
- `app-engine:apps:run` - needed for almost all tools
|
|
136
|
+
- `app-engine:functions:run` - needed for for almost all tools
|
|
137
|
+
- `environment-api:security-problems:read` - needed for reading security problems (_currently not available for Platform Tokens_)
|
|
138
|
+
- `environment-api:entities:read` - read monitored entities (_currently not available for Platform Tokens_)
|
|
139
|
+
- `environment-api:metrics:read` - read metrics (_currently not available for Platform Tokens_)
|
|
140
|
+
- `environment-api:slo:read` - read SLOs (_currently not available for Platform Tokens_)
|
|
141
|
+
- `automation:workflows:read` - read Workflows
|
|
142
|
+
- `automation:workflows:write` - create and update Workflows
|
|
143
|
+
- `automation:workflows:run` - run Workflows
|
|
144
|
+
- `storage:buckets:read` - needed for `execute_dql` tool to read all system data stored on Grail
|
|
145
|
+
- `storage:logs:read` - needed for `execute_dql` tool to read logs for reliability guardian validations
|
|
146
|
+
- `storage:metrics:read` - needed for `execute_dql` tool to read metrics for reliability guardian validations
|
|
147
|
+
- `storage:bizevents:read` - needed for `execute_dql` tool to read bizevents for reliability guardian validations
|
|
148
|
+
- `storage:spans:read` - needed for `execute_dql` tool to read spans from Grail
|
|
149
|
+
- `storage:entities:read` - needed for `execute_dql` tool to read Entities from Grail
|
|
150
|
+
- `storage:events:read` - needed for `execute_dql` tool to read Events from Grail
|
|
151
|
+
- `storage:security.events:read`- needed for `execute_dql` tool to read Security Events from Grail
|
|
152
|
+
- `storage:system:read` - needed for `execute_dql` tool to read System Data from Grail
|
|
153
|
+
- `storage:user.events:read` - needed for `execute_dql` tool to read User events from Grail
|
|
154
|
+
- `storage:user.sessions:read` - needed for `execute_dql` tool to read User sessions from Grail
|
|
155
|
+
- `davis-copilot:conversations:execute` - execute conversational skill (chat with Copilot)
|
|
156
|
+
- `davis-copilot:nl2dql:execute` - execute Davis Copilot Natural Language (NL) to DQL skill
|
|
157
|
+
- `davis-copilot:dql2nl:execute` - execute DQL to Natural Language (NL) skill
|
|
158
|
+
- `settings:objects:read` - needed for reading ownership information and Guardians (SRG) from settings
|
|
159
|
+
|
|
160
|
+
**Note**: Please ensure that `settings:objects:read` is used, and _not_ the similarly named scope `app-settings:objects:read`.
|
|
161
|
+
|
|
143
162
|
## ✨ Example prompts ✨
|
|
144
163
|
|
|
145
164
|
Use these example prompts as a starting point. Just copy them into your IDE or agent setup, adapt them to your services/stack/architecture,
|
|
146
165
|
and extend them as needed. They’re here to help you imagine how real-time observability and automation work together in the MCP context in your IDE.
|
|
147
166
|
|
|
167
|
+
**Write a DQL query from natural language:**
|
|
168
|
+
|
|
169
|
+
```
|
|
170
|
+
Show me error rates for the payment service in the last hour
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
**Explain a DQL query:**
|
|
174
|
+
|
|
175
|
+
```
|
|
176
|
+
What does this DQL do?
|
|
177
|
+
fetch logs | filter dt.source_entity == 'SERVICE-123' | summarize count(), by:{severity} | sort count() desc
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
**Chat with Davis CoPilot:**
|
|
181
|
+
|
|
182
|
+
```
|
|
183
|
+
How can I investigate slow database queries in Dynatrace?
|
|
184
|
+
```
|
|
185
|
+
|
|
148
186
|
**Find open vulnerabilities on production, setup alert.**
|
|
149
187
|
|
|
150
188
|
```
|
|
@@ -246,6 +284,8 @@ First, enable Copilot for your Workspace `.vscode/settings.json`:
|
|
|
246
284
|
}
|
|
247
285
|
```
|
|
248
286
|
|
|
287
|
+
and make sure that you are using Agent Mode in CoPilot.
|
|
288
|
+
|
|
249
289
|
Second, add the MCP to `.vscode/mcp.json`:
|
|
250
290
|
|
|
251
291
|
```json
|
|
@@ -253,7 +293,7 @@ Second, add the MCP to `.vscode/mcp.json`:
|
|
|
253
293
|
"servers": {
|
|
254
294
|
"my-dynatrace-mcp-server": {
|
|
255
295
|
"command": "node",
|
|
256
|
-
"args": ["${workspaceFolder}/dist/index.js"],
|
|
296
|
+
"args": ["--watch", "${workspaceFolder}/dist/index.js"],
|
|
257
297
|
"envFile": "${workspaceFolder}/.env"
|
|
258
298
|
}
|
|
259
299
|
}
|
|
@@ -262,7 +302,7 @@ Second, add the MCP to `.vscode/mcp.json`:
|
|
|
262
302
|
|
|
263
303
|
Third, create a `.env` file in this repository (you can copy from `.env.template`) and configure environment variables as [described above](#environment-variables).
|
|
264
304
|
|
|
265
|
-
|
|
305
|
+
Finally, make changes to your code and compile it with `npm run build` or just run `npm run watch` and it auto-compiles.
|
|
266
306
|
|
|
267
307
|
## Notes
|
|
268
308
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.
|
|
3
|
+
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 package_json_1 = require("../../package.json");
|
|
@@ -34,29 +34,40 @@ const requestToken = async (clientId, clientSecret, ssoAuthUrl, scopes) => {
|
|
|
34
34
|
return await res.json();
|
|
35
35
|
};
|
|
36
36
|
/**
|
|
37
|
-
*
|
|
37
|
+
* Create a Dynatrace Http Client (from the http-client SDK) based on the provided authentication credentails
|
|
38
|
+
* @param environmentUrl
|
|
39
|
+
* @param scopes
|
|
40
|
+
* @param clientId
|
|
41
|
+
* @param clientSecret
|
|
42
|
+
* @param dtPlatformToken
|
|
43
|
+
* @returns
|
|
38
44
|
*/
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
this.userAgent = userAgent;
|
|
45
|
+
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);
|
|
44
49
|
}
|
|
45
|
-
|
|
46
|
-
//
|
|
47
|
-
|
|
48
|
-
...options.headers,
|
|
49
|
-
'User-Agent': this.userAgent,
|
|
50
|
-
};
|
|
51
|
-
// call the parent send method
|
|
52
|
-
return super.send(options);
|
|
50
|
+
if (dtPlatformToken) {
|
|
51
|
+
// create a simple HTTP client if only the platform token is provided
|
|
52
|
+
return createBearerTokenHttpClient(environmentUrl, dtPlatformToken);
|
|
53
53
|
}
|
|
54
|
-
|
|
55
|
-
|
|
54
|
+
throw new Error('Failed to create Dynatrace HTTP Client: Please provide either clientId and clientSecret or dtPlatformToken');
|
|
55
|
+
};
|
|
56
|
+
exports.createDtHttpClient = createDtHttpClient;
|
|
57
|
+
/** Creates an HTTP Client based on environmentUrl and a platform token */
|
|
58
|
+
const createBearerTokenHttpClient = async (environmentUrl, dtPlatformToken) => {
|
|
59
|
+
return new http_client_1.PlatformHttpClient({
|
|
60
|
+
baseUrl: environmentUrl,
|
|
61
|
+
defaultHeaders: {
|
|
62
|
+
'Authorization': `Bearer ${dtPlatformToken}`,
|
|
63
|
+
'User-Agent': `dynatrace-mcp-server/v${package_json_1.version} (${process.platform}-${process.arch})`,
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
};
|
|
56
67
|
/** Create an Oauth Client based on clientId, clientSecret, environmentUrl and scopes
|
|
57
68
|
* This uses a client-credentials flow to request a token from the SSO endpoint.
|
|
58
69
|
*/
|
|
59
|
-
const
|
|
70
|
+
const createOAuthHttpClient = async (environmentUrl, scopes, clientId, clientSecret) => {
|
|
60
71
|
if (!clientId) {
|
|
61
72
|
throw new Error('Failed to retrieve OAuth client id from env "DT_APP_OAUTH_CLIENT_ID"');
|
|
62
73
|
}
|
|
@@ -77,13 +88,5 @@ const createOAuthClient = async (clientId, clientSecret, environmentUrl, scopes)
|
|
|
77
88
|
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.`);
|
|
78
89
|
}
|
|
79
90
|
console.error(`Successfully retrieved token from SSO!`);
|
|
80
|
-
|
|
81
|
-
return new ExtendedOauthClient({
|
|
82
|
-
scopes,
|
|
83
|
-
clientId,
|
|
84
|
-
secret: clientSecret,
|
|
85
|
-
environmentUrl,
|
|
86
|
-
authUrl: ssoAuthUrl,
|
|
87
|
-
}, userAgent);
|
|
91
|
+
return createBearerTokenHttpClient(environmentUrl, tokenResponse.access_token);
|
|
88
92
|
};
|
|
89
|
-
exports.createOAuthClient = createOAuthClient;
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const dynatrace_clients_1 = require("./dynatrace-clients");
|
|
4
|
+
const http_client_1 = require("@dynatrace-sdk/http-client");
|
|
5
|
+
const dt_app_1 = require("dt-app");
|
|
6
|
+
// Mock external dependencies
|
|
7
|
+
jest.mock('@dynatrace-sdk/http-client');
|
|
8
|
+
jest.mock('dt-app');
|
|
9
|
+
jest.mock('../../package.json', () => ({
|
|
10
|
+
version: '1.0.0-test',
|
|
11
|
+
}));
|
|
12
|
+
// Mock fetch globally
|
|
13
|
+
global.fetch = jest.fn();
|
|
14
|
+
const mockPlatformHttpClient = http_client_1.PlatformHttpClient;
|
|
15
|
+
const mockGetSSOUrl = dt_app_1.getSSOUrl;
|
|
16
|
+
const mockFetch = global.fetch;
|
|
17
|
+
describe('dynatrace-clients', () => {
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
jest.clearAllMocks();
|
|
20
|
+
// Reset console.error mock
|
|
21
|
+
jest.spyOn(console, 'error').mockImplementation(() => { });
|
|
22
|
+
});
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
jest.restoreAllMocks();
|
|
25
|
+
});
|
|
26
|
+
describe('createDtHttpClient', () => {
|
|
27
|
+
const environmentUrl = 'https://test123.apps.dynatrace.com';
|
|
28
|
+
const scopes = ['scope1', 'scope2'];
|
|
29
|
+
describe('with OAuth credentials', () => {
|
|
30
|
+
const clientId = 'test-client-id';
|
|
31
|
+
const clientSecret = 'test-client-secret';
|
|
32
|
+
const platformToken = 'test-platform-token';
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
mockGetSSOUrl.mockResolvedValue('https://sso.dynatrace.com');
|
|
35
|
+
});
|
|
36
|
+
it('should create OAuth client successfully', async () => {
|
|
37
|
+
const mockTokenResponse = {
|
|
38
|
+
access_token: 'test-access-token',
|
|
39
|
+
token_type: 'Bearer',
|
|
40
|
+
expires_in: 3600,
|
|
41
|
+
scope: 'scope1 scope2',
|
|
42
|
+
};
|
|
43
|
+
mockFetch.mockResolvedValueOnce({
|
|
44
|
+
ok: true,
|
|
45
|
+
json: async () => mockTokenResponse,
|
|
46
|
+
});
|
|
47
|
+
const result = await (0, dynatrace_clients_1.createDtHttpClient)(environmentUrl, scopes, clientId, clientSecret);
|
|
48
|
+
expect(mockGetSSOUrl).toHaveBeenCalledWith(environmentUrl);
|
|
49
|
+
expect(mockFetch).toHaveBeenCalledWith('https://sso.dynatrace.com/sso/oauth2/token', {
|
|
50
|
+
method: 'POST',
|
|
51
|
+
headers: {
|
|
52
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
53
|
+
},
|
|
54
|
+
body: new URLSearchParams({
|
|
55
|
+
grant_type: 'client_credentials',
|
|
56
|
+
client_id: clientId,
|
|
57
|
+
client_secret: clientSecret,
|
|
58
|
+
scope: scopes.join(' '),
|
|
59
|
+
}),
|
|
60
|
+
});
|
|
61
|
+
expect(mockPlatformHttpClient).toHaveBeenCalledWith({
|
|
62
|
+
baseUrl: environmentUrl,
|
|
63
|
+
defaultHeaders: {
|
|
64
|
+
'Authorization': 'Bearer test-access-token',
|
|
65
|
+
'User-Agent': 'dynatrace-mcp-server/v1.0.0-test (linux-x64)',
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
expect(result).toBeInstanceOf(http_client_1.PlatformHttpClient);
|
|
69
|
+
});
|
|
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
|
+
it('should throw error when token request fails with HTTP error', async () => {
|
|
77
|
+
mockFetch.mockResolvedValueOnce({
|
|
78
|
+
ok: false,
|
|
79
|
+
status: 401,
|
|
80
|
+
statusText: 'Unauthorized',
|
|
81
|
+
json: async () => ({
|
|
82
|
+
error: 'invalid_client',
|
|
83
|
+
error_description: 'Invalid client credentials',
|
|
84
|
+
}),
|
|
85
|
+
});
|
|
86
|
+
await expect((0, dynatrace_clients_1.createDtHttpClient)(environmentUrl, scopes, clientId, clientSecret)).rejects.toThrow('Failed to retrieve OAuth token');
|
|
87
|
+
expect(console.error).toHaveBeenCalledWith('Failed to fetch token: 401 Unauthorized');
|
|
88
|
+
});
|
|
89
|
+
it('should throw error when token response contains error', async () => {
|
|
90
|
+
const mockErrorResponse = {
|
|
91
|
+
error: 'invalid_scope',
|
|
92
|
+
error_description: 'The requested scope is invalid',
|
|
93
|
+
issueId: 'issue-123',
|
|
94
|
+
};
|
|
95
|
+
mockFetch.mockResolvedValueOnce({
|
|
96
|
+
ok: true,
|
|
97
|
+
json: async () => mockErrorResponse,
|
|
98
|
+
});
|
|
99
|
+
await expect((0, dynatrace_clients_1.createDtHttpClient)(environmentUrl, scopes, clientId, clientSecret)).rejects.toThrow('Failed to retrieve OAuth token (IssueId: issue-123): invalid_scope - The requested scope is invalid');
|
|
100
|
+
});
|
|
101
|
+
it('should throw error when token response is missing access_token', async () => {
|
|
102
|
+
const mockIncompleteResponse = {
|
|
103
|
+
token_type: 'Bearer',
|
|
104
|
+
expires_in: 3600,
|
|
105
|
+
};
|
|
106
|
+
mockFetch.mockResolvedValueOnce({
|
|
107
|
+
ok: true,
|
|
108
|
+
json: async () => mockIncompleteResponse,
|
|
109
|
+
});
|
|
110
|
+
await expect((0, dynatrace_clients_1.createDtHttpClient)(environmentUrl, scopes, clientId, clientSecret)).rejects.toThrow('Failed to retrieve OAuth token');
|
|
111
|
+
});
|
|
112
|
+
it('should log authentication details', async () => {
|
|
113
|
+
const mockTokenResponse = {
|
|
114
|
+
access_token: 'test-access-token',
|
|
115
|
+
token_type: 'Bearer',
|
|
116
|
+
expires_in: 3600,
|
|
117
|
+
};
|
|
118
|
+
mockFetch.mockResolvedValueOnce({
|
|
119
|
+
ok: true,
|
|
120
|
+
json: async () => mockTokenResponse,
|
|
121
|
+
});
|
|
122
|
+
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(', ')}`);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
describe('with Bearer token', () => {
|
|
127
|
+
const dtPlatformToken = 'test-platform-token';
|
|
128
|
+
it('should create Bearer token client successfully', async () => {
|
|
129
|
+
const result = await (0, dynatrace_clients_1.createDtHttpClient)(environmentUrl, scopes, undefined, undefined, dtPlatformToken);
|
|
130
|
+
expect(mockPlatformHttpClient).toHaveBeenCalledWith({
|
|
131
|
+
baseUrl: environmentUrl,
|
|
132
|
+
defaultHeaders: {
|
|
133
|
+
'Authorization': `Bearer ${dtPlatformToken}`,
|
|
134
|
+
'User-Agent': 'dynatrace-mcp-server/v1.0.0-test (linux-x64)',
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
expect(result).toBeInstanceOf(http_client_1.PlatformHttpClient);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
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
|
+
});
|
|
146
|
+
describe('requestToken function (indirectly tested)', () => {
|
|
147
|
+
it('should handle fetch errors gracefully', async () => {
|
|
148
|
+
mockGetSSOUrl.mockResolvedValue('https://sso.dynatrace.com');
|
|
149
|
+
// Mock fetch to throw an error
|
|
150
|
+
mockFetch.mockRejectedValueOnce(new Error('Network error'));
|
|
151
|
+
await expect((0, dynatrace_clients_1.createDtHttpClient)('https://test.apps.dynatrace.com', ['scope1'], 'client-id', 'client-secret')).rejects.toThrow('Network error');
|
|
152
|
+
});
|
|
153
|
+
it('should format request body correctly', async () => {
|
|
154
|
+
mockGetSSOUrl.mockResolvedValue('https://sso.dynatrace.com');
|
|
155
|
+
const mockTokenResponse = {
|
|
156
|
+
access_token: 'test-token',
|
|
157
|
+
};
|
|
158
|
+
mockFetch.mockResolvedValueOnce({
|
|
159
|
+
ok: true,
|
|
160
|
+
json: async () => mockTokenResponse,
|
|
161
|
+
});
|
|
162
|
+
await (0, dynatrace_clients_1.createDtHttpClient)('https://test.apps.dynatrace.com', ['scope1', 'scope2'], 'test-client', 'test-secret');
|
|
163
|
+
const expectedBody = new URLSearchParams({
|
|
164
|
+
grant_type: 'client_credentials',
|
|
165
|
+
client_id: 'test-client',
|
|
166
|
+
client_secret: 'test-secret',
|
|
167
|
+
scope: 'scope1 scope2',
|
|
168
|
+
});
|
|
169
|
+
expect(mockFetch).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({
|
|
170
|
+
method: 'POST',
|
|
171
|
+
headers: {
|
|
172
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
173
|
+
},
|
|
174
|
+
body: expectedBody,
|
|
175
|
+
}));
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
describe('User-Agent header', () => {
|
|
179
|
+
it('should include correct User-Agent format', async () => {
|
|
180
|
+
const dtPlatformToken = 'test-token';
|
|
181
|
+
await (0, dynatrace_clients_1.createDtHttpClient)('https://test.apps.dynatrace.com', ['scope1'], undefined, undefined, dtPlatformToken);
|
|
182
|
+
expect(mockPlatformHttpClient).toHaveBeenCalledWith(expect.objectContaining({
|
|
183
|
+
defaultHeaders: expect.objectContaining({
|
|
184
|
+
'User-Agent': expect.stringMatching(/^dynatrace-mcp-server\/v\d+\.\d+\.\d+(-\w+)? \(\w+-\w+\)$/),
|
|
185
|
+
}),
|
|
186
|
+
}));
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.chatWithDavisCopilot = exports.explainDqlInNaturalLanguage = exports.generateDqlFromNaturalLanguage = void 0;
|
|
4
|
+
// API Functions
|
|
5
|
+
/**
|
|
6
|
+
* Generate DQL from natural language
|
|
7
|
+
* Converts plain English descriptions into powerful Dynatrace Query Language (DQL) statements.
|
|
8
|
+
* DQL is the most powerful way to query any data in Dynatrace, including problem events,
|
|
9
|
+
* security issues, logs, metrics, spans, and custom data.
|
|
10
|
+
*/
|
|
11
|
+
const generateDqlFromNaturalLanguage = async (dtClient, text) => {
|
|
12
|
+
const request = { text };
|
|
13
|
+
const response = await dtClient.send({
|
|
14
|
+
method: 'POST',
|
|
15
|
+
url: '/platform/davis/copilot/v0.2/skills/nl2dql:generate',
|
|
16
|
+
headers: {
|
|
17
|
+
'Content-Type': 'application/json',
|
|
18
|
+
'Accept': 'application/json',
|
|
19
|
+
},
|
|
20
|
+
body: JSON.stringify(request),
|
|
21
|
+
});
|
|
22
|
+
return await response.body('json');
|
|
23
|
+
};
|
|
24
|
+
exports.generateDqlFromNaturalLanguage = generateDqlFromNaturalLanguage;
|
|
25
|
+
/**
|
|
26
|
+
* Explain DQL in natural language
|
|
27
|
+
* Provides plain English explanations of complex DQL queries.
|
|
28
|
+
* Helps users understand what powerful DQL statements do, including
|
|
29
|
+
* queries for problem events, security issues, and performance metrics.
|
|
30
|
+
*/
|
|
31
|
+
const explainDqlInNaturalLanguage = async (dtClient, dql) => {
|
|
32
|
+
const request = { dql };
|
|
33
|
+
const response = await dtClient.send({
|
|
34
|
+
method: 'POST',
|
|
35
|
+
url: '/platform/davis/copilot/v0.2/skills/dql2nl:explain',
|
|
36
|
+
headers: {
|
|
37
|
+
'Content-Type': 'application/json',
|
|
38
|
+
'Accept': 'application/json',
|
|
39
|
+
},
|
|
40
|
+
body: request, // Not sure why this does not need JSON.stringify, but it only works like this; once we have the SDK, this will be consistent
|
|
41
|
+
});
|
|
42
|
+
return await response.body('json');
|
|
43
|
+
};
|
|
44
|
+
exports.explainDqlInNaturalLanguage = explainDqlInNaturalLanguage;
|
|
45
|
+
const chatWithDavisCopilot = async (dtClient, text, context, annotations, state) => {
|
|
46
|
+
const request = {
|
|
47
|
+
text,
|
|
48
|
+
context,
|
|
49
|
+
annotations,
|
|
50
|
+
state,
|
|
51
|
+
};
|
|
52
|
+
const response = await dtClient.send({
|
|
53
|
+
method: 'POST',
|
|
54
|
+
url: '/platform/davis/copilot/v0.2/skills/conversations:message',
|
|
55
|
+
headers: {
|
|
56
|
+
'Content-Type': 'application/json',
|
|
57
|
+
'Accept': 'application/json',
|
|
58
|
+
},
|
|
59
|
+
body: JSON.stringify(request),
|
|
60
|
+
});
|
|
61
|
+
return await response.body('json');
|
|
62
|
+
};
|
|
63
|
+
exports.chatWithDavisCopilot = chatWithDavisCopilot;
|
|
@@ -12,13 +12,17 @@ const verifyDqlStatement = async (dtClient, dqlStatement) => {
|
|
|
12
12
|
return response;
|
|
13
13
|
};
|
|
14
14
|
exports.verifyDqlStatement = verifyDqlStatement;
|
|
15
|
-
|
|
15
|
+
/**
|
|
16
|
+
* Execute a DQL statement against the Dynatrace API.
|
|
17
|
+
* If the result is immediately available, it will be returned.
|
|
18
|
+
* If the result is not immediately available, it will poll for the result until it is available.
|
|
19
|
+
* @param dtClient
|
|
20
|
+
* @param body - Contains the DQL statement to execute, and optional parameters like maxResultRecords and maxResultBytes
|
|
21
|
+
* @returns the result without metadata and without notifications, or undefined if the query failed or no result was returned.
|
|
22
|
+
*/
|
|
23
|
+
const executeDql = async (dtClient, body) => {
|
|
16
24
|
const queryExecutionClient = new client_query_1.QueryExecutionClient(dtClient);
|
|
17
|
-
const response = await queryExecutionClient.queryExecute({
|
|
18
|
-
body: {
|
|
19
|
-
query: dqlStatement,
|
|
20
|
-
},
|
|
21
|
-
});
|
|
25
|
+
const response = await queryExecutionClient.queryExecute({ body });
|
|
22
26
|
if (response.result) {
|
|
23
27
|
// return response result immediately
|
|
24
28
|
return response.result.records;
|
|
@@ -8,7 +8,7 @@ const findMonitoredEntityByName = async (dtClient, entityName) => {
|
|
|
8
8
|
| append [fetch dt.entity.host | search "*${entityName}*" | fieldsAdd entity.type]
|
|
9
9
|
| append [fetch dt.entity.process_group | search "*${entityName}*" | fieldsAdd entity.type]
|
|
10
10
|
| append [fetch dt.entity.cloud_application | search "*${entityName}*" | fieldsAdd entity.type]`;
|
|
11
|
-
const dqlResponse = await (0, execute_dql_1.executeDql)(dtClient, dql);
|
|
11
|
+
const dqlResponse = await (0, execute_dql_1.executeDql)(dtClient, { query: dql });
|
|
12
12
|
if (dqlResponse && dqlResponse.length > 0) {
|
|
13
13
|
let resp = 'The following monitored entities were found:\n';
|
|
14
14
|
// iterate over dqlResponse and create a string with the entity names
|
|
@@ -8,6 +8,6 @@ const getEventsForCluster = async (dtClient, clusterId) => {
|
|
|
8
8
|
// if no clusterId is provided, we need to fetch all events
|
|
9
9
|
dql = `fetch events | filter isNotNull(k8s.cluster.uid)`;
|
|
10
10
|
}
|
|
11
|
-
return (0, execute_dql_1.executeDql)(dtClient, dql);
|
|
11
|
+
return (0, execute_dql_1.executeDql)(dtClient, { query: dql });
|
|
12
12
|
};
|
|
13
13
|
exports.getEventsForCluster = getEventsForCluster;
|
|
@@ -4,6 +4,6 @@ exports.getLogsForEntity = void 0;
|
|
|
4
4
|
const execute_dql_1 = require("./execute-dql");
|
|
5
5
|
const getLogsForEntity = async (dtClient, entityId) => {
|
|
6
6
|
const dql = `fetch logs | filter dt.source_entity == "${entityId}"`;
|
|
7
|
-
return (0, execute_dql_1.executeDql)(dtClient, dql);
|
|
7
|
+
return (0, execute_dql_1.executeDql)(dtClient, { query: dql });
|
|
8
8
|
};
|
|
9
9
|
exports.getLogsForEntity = getLogsForEntity;
|
|
@@ -1,15 +1,28 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.listProblems = void 0;
|
|
4
|
-
const
|
|
5
|
-
const listProblems = async (dtClient) => {
|
|
6
|
-
|
|
7
|
-
const
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
4
|
+
const execute_dql_1 = require("./execute-dql");
|
|
5
|
+
const listProblems = async (dtClient, additionalFilter) => {
|
|
6
|
+
// DQL Statement from Problems App to fetch all Davis Problems for the last 12 hours to now
|
|
7
|
+
const dql = `fetch dt.davis.problems, from: now()-12h, to: now()
|
|
8
|
+
| filter isNull(dt.davis.is_duplicate) OR not(dt.davis.is_duplicate)
|
|
9
|
+
${additionalFilter ? `| filter ${additionalFilter}` : ''}
|
|
10
|
+
| fieldsAdd
|
|
11
|
+
duration = coalesce(event.end, now()) - event.start,
|
|
12
|
+
affected_entities_count = arraySize(affected_entity_ids),
|
|
13
|
+
event_count = arraySize(dt.davis.event_ids),
|
|
14
|
+
affected_users_count = dt.davis.affected_users_count,
|
|
15
|
+
problem_id = event.id
|
|
16
|
+
| fields display_id, event.name, event.description, event.status, event.category, event.start, event.end,
|
|
17
|
+
root_cause_entity_id, root_cause_entity_name, duration, affected_entities_count,
|
|
18
|
+
event_count, affected_users_count, problem_id, dt.davis.mute.status, dt.davis.mute.user,
|
|
19
|
+
entity_tags, labels.alerting_profile, maintenance.is_under_maintenance,
|
|
20
|
+
aws.account.id, azure.resource.group, azure.subscription, cloud.provider, cloud.region,
|
|
21
|
+
dt.cost.costcenter, dt.cost.product, dt.host_group.id, dt.security_context, gcp.project.id,
|
|
22
|
+
host.name,
|
|
23
|
+
k8s.cluster.name, k8s.cluster.uid, k8s.container.name, k8s.namespace.name, k8s.node.name, k8s.pod.name, k8s.service.name, k8s.workload.kind, k8s.workload.name
|
|
24
|
+
| sort event.status asc, event.start desc
|
|
25
|
+
`;
|
|
26
|
+
return await (0, execute_dql_1.executeDql)(dtClient, { query: dql, maxResultRecords: 5000, maxResultBytes: /* 5 MB */ 5000000 });
|
|
14
27
|
};
|
|
15
28
|
exports.listProblems = listProblems;
|
package/dist/getDynatraceEnv.js
CHANGED
|
@@ -6,12 +6,16 @@ exports.getDynatraceEnv = getDynatraceEnv;
|
|
|
6
6
|
* Throws an Error if validation fails.
|
|
7
7
|
*/
|
|
8
8
|
function getDynatraceEnv(env = process.env) {
|
|
9
|
-
const
|
|
9
|
+
const oauthClientId = env.OAUTH_CLIENT_ID;
|
|
10
10
|
const oauthClientSecret = env.OAUTH_CLIENT_SECRET;
|
|
11
|
+
const dtPlatformToken = env.DT_PLATFORM_TOKEN;
|
|
11
12
|
const dtEnvironment = env.DT_ENVIRONMENT;
|
|
12
13
|
const slackConnectionId = env.SLACK_CONNECTION_ID || 'fake-slack-connection-id';
|
|
13
|
-
if (!
|
|
14
|
-
throw new Error('Please set
|
|
14
|
+
if (!dtEnvironment) {
|
|
15
|
+
throw new Error('Please set DT_ENVIRONMENT environment variable to your Dynatrace Platform Environment');
|
|
16
|
+
}
|
|
17
|
+
if (!oauthClientId && !oauthClientSecret && !dtPlatformToken) {
|
|
18
|
+
throw new Error('Please set either OAUTH_CLIENT_ID and OAUTH_CLIENT_SECRET, or DT_PLATFORM_TOKEN environment variables');
|
|
15
19
|
}
|
|
16
20
|
if (!dtEnvironment.startsWith('https://')) {
|
|
17
21
|
throw new Error('Please set DT_ENVIRONMENT to a valid Dynatrace Environment URL (e.g., https://<environment-id>.apps.dynatrace.com)');
|
|
@@ -19,5 +23,5 @@ function getDynatraceEnv(env = process.env) {
|
|
|
19
23
|
if (!dtEnvironment.includes('apps.dynatrace.com') && !dtEnvironment.includes('apps.dynatracelabs.com')) {
|
|
20
24
|
throw new Error('Please set DT_ENVIRONMENT to a valid Dynatrace Platform Environment URL (e.g., https://<environment-id>.apps.dynatrace.com)');
|
|
21
25
|
}
|
|
22
|
-
return {
|
|
26
|
+
return { oauthClientId, oauthClientSecret, dtPlatformToken, dtEnvironment, slackConnectionId };
|
|
23
27
|
}
|
|
@@ -6,15 +6,17 @@ describe('getDynatraceEnv', () => {
|
|
|
6
6
|
OAUTH_CLIENT_ID: 'dt0s02.SAMPLE',
|
|
7
7
|
OAUTH_CLIENT_SECRET: 'dt0s02.SAMPLE.abcd1234',
|
|
8
8
|
DT_ENVIRONMENT: 'https://abc123.apps.dynatrace.com',
|
|
9
|
+
DT_PLATFORM_TOKEN: 'dt0s16.SAMPLE.abcd1234',
|
|
9
10
|
SLACK_CONNECTION_ID: 'slack-conn-id',
|
|
10
11
|
};
|
|
11
12
|
it('returns all required values when environment is valid', () => {
|
|
12
13
|
const env = { ...baseEnv };
|
|
13
14
|
const result = (0, getDynatraceEnv_1.getDynatraceEnv)(env);
|
|
14
15
|
expect(result).toEqual({
|
|
15
|
-
|
|
16
|
+
oauthClientId: env.OAUTH_CLIENT_ID,
|
|
16
17
|
oauthClientSecret: env.OAUTH_CLIENT_SECRET,
|
|
17
18
|
dtEnvironment: env.DT_ENVIRONMENT,
|
|
19
|
+
dtPlatformToken: env.DT_PLATFORM_TOKEN,
|
|
18
20
|
slackConnectionId: env.SLACK_CONNECTION_ID,
|
|
19
21
|
});
|
|
20
22
|
});
|
|
@@ -23,14 +25,15 @@ describe('getDynatraceEnv', () => {
|
|
|
23
25
|
const result = (0, getDynatraceEnv_1.getDynatraceEnv)(env);
|
|
24
26
|
expect(result.slackConnectionId).toBe('fake-slack-connection-id');
|
|
25
27
|
});
|
|
26
|
-
it('throws if
|
|
27
|
-
const env = {
|
|
28
|
+
it('throws if environment variables for auth credentials are missing', () => {
|
|
29
|
+
const env = {
|
|
30
|
+
...baseEnv,
|
|
31
|
+
OAUTH_CLIENT_ID: undefined,
|
|
32
|
+
OAUTH_CLIENT_SECRET: undefined,
|
|
33
|
+
DT_PLATFORM_TOKEN: undefined,
|
|
34
|
+
};
|
|
28
35
|
expect(() => (0, getDynatraceEnv_1.getDynatraceEnv)(env)).toThrow(/OAUTH_CLIENT_ID/);
|
|
29
36
|
});
|
|
30
|
-
it('throws if OAUTH_CLIENT_SECRET is missing', () => {
|
|
31
|
-
const env = { ...baseEnv, OAUTH_CLIENT_SECRET: undefined };
|
|
32
|
-
expect(() => (0, getDynatraceEnv_1.getDynatraceEnv)(env)).toThrow(/OAUTH_CLIENT_SECRET/);
|
|
33
|
-
});
|
|
34
37
|
it('throws if DT_ENVIRONMENT is missing', () => {
|
|
35
38
|
const env = { ...baseEnv, DT_ENVIRONMENT: undefined };
|
|
36
39
|
expect(() => (0, getDynatraceEnv_1.getDynatraceEnv)(env)).toThrow(/DT_ENVIRONMENT/);
|
package/dist/index.js
CHANGED
|
@@ -11,7 +11,6 @@ const package_json_1 = require("../package.json");
|
|
|
11
11
|
const dynatrace_clients_1 = require("./authentication/dynatrace-clients");
|
|
12
12
|
const list_vulnerabilities_1 = require("./capabilities/list-vulnerabilities");
|
|
13
13
|
const list_problems_1 = require("./capabilities/list-problems");
|
|
14
|
-
const get_problem_details_1 = require("./capabilities/get-problem-details");
|
|
15
14
|
const get_monitored_entity_details_1 = require("./capabilities/get-monitored-entity-details");
|
|
16
15
|
const get_ownership_information_1 = require("./capabilities/get-ownership-information");
|
|
17
16
|
const get_logs_for_entity_1 = require("./capabilities/get-logs-for-entity");
|
|
@@ -22,6 +21,7 @@ const get_vulnerability_details_1 = require("./capabilities/get-vulnerability-de
|
|
|
22
21
|
const execute_dql_1 = require("./capabilities/execute-dql");
|
|
23
22
|
const send_slack_message_1 = require("./capabilities/send-slack-message");
|
|
24
23
|
const find_monitored_entity_by_name_1 = require("./capabilities/find-monitored-entity-by-name");
|
|
24
|
+
const davis_copilot_1 = require("./capabilities/davis-copilot");
|
|
25
25
|
const getDynatraceEnv_1 = require("./getDynatraceEnv");
|
|
26
26
|
(0, dotenv_1.config)();
|
|
27
27
|
let scopesBase = [
|
|
@@ -38,7 +38,8 @@ const main = async () => {
|
|
|
38
38
|
console.error(err.message);
|
|
39
39
|
process.exit(1);
|
|
40
40
|
}
|
|
41
|
-
|
|
41
|
+
console.error(`Initializing Dynatrace MCP Server v${package_json_1.version}...`);
|
|
42
|
+
const { oauthClientId, oauthClientSecret, dtEnvironment, dtPlatformToken, slackConnectionId } = dynatraceEnv;
|
|
42
43
|
console.error(`Starting Dynatrace MCP Server v${package_json_1.version}...`);
|
|
43
44
|
const server = new mcp_js_1.McpServer({
|
|
44
45
|
name: 'Dynatrace MCP Server',
|
|
@@ -88,7 +89,7 @@ const main = async () => {
|
|
|
88
89
|
};
|
|
89
90
|
tool('get_environment_info', 'Get information about the connected Dynatrace Environment (Tenant)', {}, async ({}) => {
|
|
90
91
|
// create an oauth-client
|
|
91
|
-
const dtClient = await (0, dynatrace_clients_1.
|
|
92
|
+
const dtClient = await (0, dynatrace_clients_1.createDtHttpClient)(dtEnvironment, scopesBase, oauthClientId, oauthClientSecret, dtPlatformToken);
|
|
92
93
|
const environmentInformationClient = new client_platform_management_service_1.EnvironmentInformationClient(dtClient);
|
|
93
94
|
const environmentInfo = await environmentInformationClient.getEnvironmentInformation();
|
|
94
95
|
let resp = `Environment Information (also referred to as tenant):
|
|
@@ -97,7 +98,7 @@ const main = async () => {
|
|
|
97
98
|
return resp;
|
|
98
99
|
});
|
|
99
100
|
tool('list_vulnerabilities', 'List all vulnerabilities from Dynatrace', {}, async ({}) => {
|
|
100
|
-
const dtClient = await (0, dynatrace_clients_1.
|
|
101
|
+
const dtClient = await (0, dynatrace_clients_1.createDtHttpClient)(dtEnvironment, scopesBase.concat('environment-api:security-problems:read'), oauthClientId, oauthClientSecret, dtPlatformToken);
|
|
101
102
|
const result = await (0, list_vulnerabilities_1.listVulnerabilities)(dtClient);
|
|
102
103
|
if (!result || result.length === 0) {
|
|
103
104
|
return 'No vulnerabilities found';
|
|
@@ -112,7 +113,7 @@ const main = async () => {
|
|
|
112
113
|
tool('get_vulnerabilty_details', 'Get details of a vulnerability by `securityProblemId` on Dynatrace', {
|
|
113
114
|
securityProblemId: zod_1.z.string().optional(),
|
|
114
115
|
}, async ({ securityProblemId }) => {
|
|
115
|
-
const dtClient = await (0, dynatrace_clients_1.
|
|
116
|
+
const dtClient = await (0, dynatrace_clients_1.createDtHttpClient)(dtEnvironment, scopesBase.concat('environment-api:security-problems:read'), oauthClientId, oauthClientSecret, dtPlatformToken);
|
|
116
117
|
const result = await (0, get_vulnerability_details_1.getVulnerabilityDetails)(dtClient, securityProblemId);
|
|
117
118
|
let resp = `The Security Problem (Vulnerability) ${result.displayId} with securityProblemId ${result.securityProblemId} has the title ${result.title}.\n`;
|
|
118
119
|
resp += `The related CVEs are ${result.cveIds?.join(',') || 'unknown'}.\n`;
|
|
@@ -157,49 +158,55 @@ const main = async () => {
|
|
|
157
158
|
resp += `Tell the user to access the link ${dtEnvironment}/ui/apps/dynatrace.security.vulnerabilities/vulnerabilities/${result.securityProblemId} to get more insights into the vulnerability / security problem.\n`;
|
|
158
159
|
return resp;
|
|
159
160
|
});
|
|
160
|
-
tool('list_problems', 'List all problems known on Dynatrace
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
if (result.rootCauseEntity) {
|
|
180
|
-
resp += `The possible root-cause could be in entity ${result.rootCauseEntity?.name} with \`entityId\` ${result.rootCauseEntity?.entityId?.id}.\n`;
|
|
181
|
-
}
|
|
182
|
-
if (result.impactAnalysis) {
|
|
183
|
-
let estimatedAffectedUsers = 0;
|
|
184
|
-
result.impactAnalysis.impacts.forEach((impact) => {
|
|
185
|
-
estimatedAffectedUsers += impact.estimatedAffectedUsers;
|
|
161
|
+
tool('list_problems', 'List all problems (dt.davis.problems) known on Dynatrace, sorted by their recency, for the last 12h. An additional filter can be provided using DQL filter.', {
|
|
162
|
+
additionalFilter: zod_1.z
|
|
163
|
+
.string()
|
|
164
|
+
.optional()
|
|
165
|
+
.describe('Additional filter for DQL statement for dt.davis.problems, e.g., \'entity_tags == array("dt.owner:team-foobar", "tag:tag")\''),
|
|
166
|
+
maxProblemsToDisplay: zod_1.z.number().default(10).describe('Maximum number of problems to display in the response.'),
|
|
167
|
+
}, async ({ additionalFilter, maxProblemsToDisplay }) => {
|
|
168
|
+
const dtClient = await (0, dynatrace_clients_1.createDtHttpClient)(dtEnvironment, scopesBase.concat('storage:events:read', 'storage:buckets:read'), oauthClientId, oauthClientSecret, dtPlatformToken);
|
|
169
|
+
// get problems (uses fetch)
|
|
170
|
+
const result = await (0, list_problems_1.listProblems)(dtClient, additionalFilter);
|
|
171
|
+
if (result && result.length > 0) {
|
|
172
|
+
let resp = `Found ${result.length} problems! Displaying the top ${maxProblemsToDisplay} problems:\n`;
|
|
173
|
+
// iterate over dqlResponse and create a string with the problem details, but only show the top maxProblemsToDisplay problems
|
|
174
|
+
result.slice(0, maxProblemsToDisplay).forEach((problem) => {
|
|
175
|
+
if (problem) {
|
|
176
|
+
resp += `Problem ${problem['display_id']} (please refer to this problem with \`problemId\` or \`event.id\` ${problem['problem_id']}))
|
|
177
|
+
with event.status ${problem['event.status']}, event.category ${problem['event.category']}: ${problem['event.name']} -
|
|
178
|
+
affects ${problem['affected_users_count']} users and ${problem['affected_entity_count']} entities for a duration of ${problem['duration']}\n`;
|
|
179
|
+
}
|
|
186
180
|
});
|
|
187
|
-
resp +=
|
|
181
|
+
resp +=
|
|
182
|
+
`\nNext Steps:` +
|
|
183
|
+
`\n1. Use "execute_dql" tool with the following query to get more details about a specific problem:
|
|
184
|
+
"fetch dt.davis.problems, from: now()-10h, to: now() | filter event.id == \"<problem-id>\" | fields event.description, event.status, event.category, event.start, event.end,
|
|
185
|
+
root_cause_entity_id, root_cause_entity_name, duration, affected_entities_count,
|
|
186
|
+
event_count, affected_users_count, problem_id, dt.davis.mute.status, dt.davis.mute.user,
|
|
187
|
+
entity_tags, labels.alerting_profile, maintenance.is_under_maintenance,
|
|
188
|
+
aws.account.id, azure.resource.group, azure.subscription, cloud.provider, cloud.region,
|
|
189
|
+
dt.cost.costcenter, dt.cost.product, dt.host_group.id, dt.security_context, gcp.project.id,
|
|
190
|
+
host.name, k8s.cluster.name, k8s.cluster.uid, k8s.container.name, k8s.namespace.name, k8s.node.name, k8s.pod.name, k8s.service.name, k8s.workload.kind, k8s.workload.name"` +
|
|
191
|
+
`\n2. Use "chat_with_davis_copilot" tool and provide \`problemId\` as context, to get insights about a specific problem via Davis Copilot.` +
|
|
192
|
+
`\n3. Tell the user to visit ${dtEnvironment}/ui/apps/dynatrace.davis.problems/problem/<problem-id> for more details.`;
|
|
193
|
+
return resp;
|
|
194
|
+
}
|
|
195
|
+
else {
|
|
196
|
+
return 'No problems found';
|
|
188
197
|
}
|
|
189
|
-
resp += `Tell the user to access the link ${dtEnvironment}/ui/apps/dynatrace.davis.problems/problem/${result.problemId} to get more insights into the problem.\n`;
|
|
190
|
-
return resp;
|
|
191
198
|
});
|
|
192
199
|
tool('find_entity_by_name', 'Get the entityId of a monitored entity based on the name of the entity on Dynatrace', {
|
|
193
200
|
entityName: zod_1.z.string(),
|
|
194
201
|
}, async ({ entityName }) => {
|
|
195
|
-
const dtClient = await (0, dynatrace_clients_1.
|
|
202
|
+
const dtClient = await (0, dynatrace_clients_1.createDtHttpClient)(dtEnvironment, scopesBase.concat('environment-api:entities:read', 'storage:entities:read'), oauthClientId, oauthClientSecret, dtPlatformToken);
|
|
196
203
|
const entityResponse = await (0, find_monitored_entity_by_name_1.findMonitoredEntityByName)(dtClient, entityName);
|
|
197
204
|
return entityResponse;
|
|
198
205
|
});
|
|
199
206
|
tool('get_entity_details', 'Get details of a monitored entity based on the entityId on Dynatrace', {
|
|
200
207
|
entityId: zod_1.z.string().optional(),
|
|
201
208
|
}, async ({ entityId }) => {
|
|
202
|
-
const dtClient = await (0, dynatrace_clients_1.
|
|
209
|
+
const dtClient = await (0, dynatrace_clients_1.createDtHttpClient)(dtEnvironment, scopesBase.concat('environment-api:entities:read'), oauthClientId, oauthClientSecret, dtPlatformToken);
|
|
203
210
|
const entityDetails = await (0, get_monitored_entity_details_1.getMonitoredEntityDetails)(dtClient, entityId);
|
|
204
211
|
let resp = `Entity ${entityDetails.displayName} of type ${entityDetails.type} with \`entityId\` ${entityDetails.entityId}\n` +
|
|
205
212
|
`Properties: ${JSON.stringify(entityDetails.properties)}\n`;
|
|
@@ -221,21 +228,21 @@ const main = async () => {
|
|
|
221
228
|
channel: zod_1.z.string().optional(),
|
|
222
229
|
message: zod_1.z.string().optional(),
|
|
223
230
|
}, async ({ channel, message }) => {
|
|
224
|
-
const dtClient = await (0, dynatrace_clients_1.
|
|
231
|
+
const dtClient = await (0, dynatrace_clients_1.createDtHttpClient)(dtEnvironment, scopesBase.concat('app-settings:objects:read'), oauthClientId, oauthClientSecret, dtPlatformToken);
|
|
225
232
|
const response = await (0, send_slack_message_1.sendSlackMessage)(dtClient, slackConnectionId, channel, message);
|
|
226
233
|
return `Message sent to Slack channel: ${JSON.stringify(response)}`;
|
|
227
234
|
});
|
|
228
235
|
tool('get_logs_for_entity', 'Get Logs for a monitored entity based on name of the entity on Dynatrace', {
|
|
229
236
|
entityName: zod_1.z.string().optional(),
|
|
230
237
|
}, async ({ entityName }) => {
|
|
231
|
-
const dtClient = await (0, dynatrace_clients_1.
|
|
238
|
+
const dtClient = await (0, dynatrace_clients_1.createDtHttpClient)(dtEnvironment, scopesBase.concat('storage:logs:read'), oauthClientId, oauthClientSecret, dtPlatformToken);
|
|
232
239
|
const logs = await (0, get_logs_for_entity_1.getLogsForEntity)(dtClient, entityName);
|
|
233
240
|
return `Logs:\n${JSON.stringify(logs?.map((logLine) => (logLine ? logLine.content : 'Empty log')))}`;
|
|
234
241
|
});
|
|
235
242
|
tool('verify_dql', 'Verify a Dynatrace Query Language (DQL) statement on Dynatrace GRAIL before executing it. This is useful to ensure that the DQL statement is valid and can be executed without errors.', {
|
|
236
243
|
dqlStatement: zod_1.z.string(),
|
|
237
244
|
}, async ({ dqlStatement }) => {
|
|
238
|
-
const dtClient = await (0, dynatrace_clients_1.
|
|
245
|
+
const dtClient = await (0, dynatrace_clients_1.createDtHttpClient)(dtEnvironment, scopesBase, oauthClientId, oauthClientSecret, dtPlatformToken);
|
|
239
246
|
const response = await (0, execute_dql_1.verifyDqlStatement)(dtClient, dqlStatement);
|
|
240
247
|
let resp = 'DQL Statement Verification:\n';
|
|
241
248
|
if (response.notifications && response.notifications.length > 0) {
|
|
@@ -255,7 +262,7 @@ const main = async () => {
|
|
|
255
262
|
tool('execute_dql', 'Get Logs, Metrics, Spans or Events from Dynatrace GRAIL by executing a Dynatrace Query Language (DQL) statement. Always use "verify_dql" tool before you execute a DQL statement. A valid statement looks like this: "fetch [logs, metrics, spans, events] | filter <some-filter> | summarize count(), by:{some-fields}. Adapt filters for certain attributes: `traceId` could be `trace_id` or `trace.id`.', {
|
|
256
263
|
dqlStatement: zod_1.z.string(),
|
|
257
264
|
}, async ({ dqlStatement }) => {
|
|
258
|
-
const dtClient = await (0, dynatrace_clients_1.
|
|
265
|
+
const dtClient = await (0, dynatrace_clients_1.createDtHttpClient)(dtEnvironment, scopesBase.concat('storage:buckets:read', // Read all system data stored on Grail
|
|
259
266
|
'storage:logs:read', // Read logs for reliability guardian validations
|
|
260
267
|
'storage:metrics:read', // Read metrics for reliability guardian validations
|
|
261
268
|
'storage:bizevents:read', // Read bizevents for reliability guardian validations
|
|
@@ -265,17 +272,102 @@ const main = async () => {
|
|
|
265
272
|
'storage:system:read', // Read System Data from Grail
|
|
266
273
|
'storage:user.events:read', // Read User events from Grail
|
|
267
274
|
'storage:user.sessions:read', // Read User sessions from Grail
|
|
268
|
-
'storage:security.events:read'));
|
|
269
|
-
const response = await (0, execute_dql_1.executeDql)(dtClient, dqlStatement);
|
|
275
|
+
'storage:security.events:read'), oauthClientId, oauthClientSecret, dtPlatformToken);
|
|
276
|
+
const response = await (0, execute_dql_1.executeDql)(dtClient, { query: dqlStatement });
|
|
270
277
|
return `DQL Response: ${JSON.stringify(response)}`;
|
|
271
278
|
});
|
|
279
|
+
tool('generate_dql_from_natural_language', "Convert natural language queries to Dynatrace Query Language (DQL) using Davis CoPilot AI. You can ask for problem events, security issues, logs, metrics, spans, and custom data. Workflow: 1) Generate DQL, 2) Verify with verify_dql tool, 3) Execute with execute_dql tool, 4) Iterate if results don't match expectations.", {
|
|
280
|
+
text: zod_1.z
|
|
281
|
+
.string()
|
|
282
|
+
.describe('Natural language description of what you want to query. Be specific and include time ranges, entities, and metrics of interest.'),
|
|
283
|
+
}, async ({ text }) => {
|
|
284
|
+
const dtClient = await (0, dynatrace_clients_1.createDtHttpClient)(dtEnvironment, scopesBase.concat('davis-copilot:nl2dql:execute'), oauthClientId, oauthClientSecret, dtPlatformToken);
|
|
285
|
+
const response = await (0, davis_copilot_1.generateDqlFromNaturalLanguage)(dtClient, text);
|
|
286
|
+
let resp = `🔤 Natural Language to DQL:\n\n`;
|
|
287
|
+
resp += `**Query:** "${text}"\n\n`;
|
|
288
|
+
resp += `**Generated DQL:**\n\`\`\`\n${response.dql}\n\`\`\`\n\n`;
|
|
289
|
+
resp += `**Status:** ${response.status}\n`;
|
|
290
|
+
resp += `**Message Token:** ${response.messageToken}\n`;
|
|
291
|
+
if (response.metadata?.notifications && response.metadata.notifications.length > 0) {
|
|
292
|
+
resp += `\n**Notifications:**\n`;
|
|
293
|
+
response.metadata.notifications.forEach((notification) => {
|
|
294
|
+
resp += `- ${notification.severity}: ${notification.message}\n`;
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
resp += `\n💡 **Next Steps:**\n`;
|
|
298
|
+
resp += `1. Use "verify_dql" tool to validate this query\n`;
|
|
299
|
+
resp += `2. Use "execute_dql" tool to run the query\n`;
|
|
300
|
+
resp += `3. If results don't match expectations, refine your natural language description and try again\n`;
|
|
301
|
+
return resp;
|
|
302
|
+
});
|
|
303
|
+
tool('explain_dql_in_natural_language', 'Explain Dynatrace Query Language (DQL) statements in natural language using Davis CoPilot AI.', {
|
|
304
|
+
dql: zod_1.z.string().describe('The DQL statement to explain'),
|
|
305
|
+
}, async ({ dql }) => {
|
|
306
|
+
const dtClient = await (0, dynatrace_clients_1.createDtHttpClient)(dtEnvironment, scopesBase.concat('davis-copilot:dql2nl:execute'), oauthClientId, oauthClientSecret, dtPlatformToken);
|
|
307
|
+
const response = await (0, davis_copilot_1.explainDqlInNaturalLanguage)(dtClient, dql);
|
|
308
|
+
let resp = `📝 DQL to Natural Language:\n\n`;
|
|
309
|
+
resp += `**DQL Query:**\n\`\`\`\n${dql}\n\`\`\`\n\n`;
|
|
310
|
+
resp += `**Summary:** ${response.summary}\n\n`;
|
|
311
|
+
resp += `**Detailed Explanation:**\n${response.explanation}\n\n`;
|
|
312
|
+
resp += `**Status:** ${response.status}\n`;
|
|
313
|
+
resp += `**Message Token:** ${response.messageToken}\n`;
|
|
314
|
+
if (response.metadata?.notifications && response.metadata.notifications.length > 0) {
|
|
315
|
+
resp += `\n**Notifications:**\n`;
|
|
316
|
+
response.metadata.notifications.forEach((notification) => {
|
|
317
|
+
resp += `- ${notification.severity}: ${notification.message}\n`;
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
return resp;
|
|
321
|
+
});
|
|
322
|
+
tool('chat_with_davis_copilot', 'Use this tool in case no specific tool is available. Get an answer to any Dynatrace related question as well as troubleshooting, and guidance. *(Note: Davis CoPilot AI is GA, but the Davis CoPilot APIs are in preview)*', {
|
|
323
|
+
text: zod_1.z.string().describe('Your question or request for Davis CoPilot'),
|
|
324
|
+
context: zod_1.z.string().optional().describe('Optional context to provide additional information'),
|
|
325
|
+
instruction: zod_1.z.string().optional().describe('Optional instruction for how to format the response'),
|
|
326
|
+
}, async ({ text, context, instruction }) => {
|
|
327
|
+
const dtClient = await (0, dynatrace_clients_1.createDtHttpClient)(dtEnvironment, scopesBase.concat('davis-copilot:conversations:execute'), oauthClientId, oauthClientSecret, dtPlatformToken);
|
|
328
|
+
const conversationContext = [];
|
|
329
|
+
if (context) {
|
|
330
|
+
conversationContext.push({
|
|
331
|
+
type: 'supplementary',
|
|
332
|
+
value: context,
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
if (instruction) {
|
|
336
|
+
conversationContext.push({
|
|
337
|
+
type: 'instruction',
|
|
338
|
+
value: instruction,
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
const response = await (0, davis_copilot_1.chatWithDavisCopilot)(dtClient, text, conversationContext);
|
|
342
|
+
let resp = `🤖 Davis CoPilot Response:\n\n`;
|
|
343
|
+
resp += `**Your Question:** "${text}"\n\n`;
|
|
344
|
+
resp += `**Answer:**\n${response.text}\n\n`;
|
|
345
|
+
resp += `**Status:** ${response.status}\n`;
|
|
346
|
+
resp += `**Message Token:** ${response.messageToken}\n`;
|
|
347
|
+
if (response.metadata?.sources && response.metadata.sources.length > 0) {
|
|
348
|
+
resp += `\n**Sources:**\n`;
|
|
349
|
+
response.metadata.sources.forEach((source) => {
|
|
350
|
+
resp += `- ${source.title || 'Untitled'}: ${source.url || 'No URL'}\n`;
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
if (response.metadata?.notifications && response.metadata.notifications.length > 0) {
|
|
354
|
+
resp += `\n**Notifications:**\n`;
|
|
355
|
+
response.metadata.notifications.forEach((notification) => {
|
|
356
|
+
resp += `- ${notification.severity}: ${notification.message}\n`;
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
if (response.state?.conversationId) {
|
|
360
|
+
resp += `\n**Conversation ID:** ${response.state.conversationId}`;
|
|
361
|
+
}
|
|
362
|
+
return resp;
|
|
363
|
+
});
|
|
272
364
|
tool('create_workflow_for_notification', 'Create a notification for a team based on a problem type within Workflows in Dynatrace', {
|
|
273
365
|
problemType: zod_1.z.string().optional(),
|
|
274
366
|
teamName: zod_1.z.string().optional(),
|
|
275
367
|
channel: zod_1.z.string().optional(),
|
|
276
368
|
isPrivate: zod_1.z.boolean().optional().default(false),
|
|
277
369
|
}, async ({ problemType, teamName, channel, isPrivate }) => {
|
|
278
|
-
const dtClient = await (0, dynatrace_clients_1.
|
|
370
|
+
const dtClient = await (0, dynatrace_clients_1.createDtHttpClient)(dtEnvironment, scopesBase.concat('automation:workflows:write', 'automation:workflows:read', 'automation:workflows:run'), oauthClientId, oauthClientSecret, dtPlatformToken);
|
|
279
371
|
const response = await (0, create_workflow_for_problem_notification_1.createWorkflowForProblemNotification)(dtClient, teamName, channel, problemType, isPrivate);
|
|
280
372
|
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`;
|
|
281
373
|
if (response.type == 'SIMPLE') {
|
|
@@ -292,7 +384,7 @@ const main = async () => {
|
|
|
292
384
|
tool('make_workflow_public', 'Modify a workflow and make it publicly available to everyone on the Dynatrace Environment', {
|
|
293
385
|
workflowId: zod_1.z.string().optional(),
|
|
294
386
|
}, async ({ workflowId }) => {
|
|
295
|
-
const dtClient = await (0, dynatrace_clients_1.
|
|
387
|
+
const dtClient = await (0, dynatrace_clients_1.createDtHttpClient)(dtEnvironment, scopesBase.concat('automation:workflows:write', 'automation:workflows:read', 'automation:workflows:run'), oauthClientId, oauthClientSecret, dtPlatformToken);
|
|
296
388
|
const response = await (0, update_workflow_1.updateWorkflow)(dtClient, workflowId, {
|
|
297
389
|
isPrivate: false,
|
|
298
390
|
});
|
|
@@ -304,14 +396,14 @@ const main = async () => {
|
|
|
304
396
|
.optional()
|
|
305
397
|
.describe(`The Kubernetes (K8s) Cluster Id, referred to as k8s.cluster.uid (this is NOT the Dynatrace environment)`),
|
|
306
398
|
}, async ({ clusterId }) => {
|
|
307
|
-
const dtClient = await (0, dynatrace_clients_1.
|
|
399
|
+
const dtClient = await (0, dynatrace_clients_1.createDtHttpClient)(dtEnvironment, scopesBase.concat('storage:events:read'), oauthClientId, oauthClientSecret, dtPlatformToken);
|
|
308
400
|
const events = await (0, get_events_for_cluster_1.getEventsForCluster)(dtClient, clusterId);
|
|
309
401
|
return `Kubernetes Events:\n${JSON.stringify(events)}`;
|
|
310
402
|
});
|
|
311
403
|
tool('get_ownership', 'Get detailed Ownership information for one or multiple entities on Dynatrace', {
|
|
312
404
|
entityIds: zod_1.z.string().optional().describe('Comma separated list of entityIds'),
|
|
313
405
|
}, async ({ entityIds }) => {
|
|
314
|
-
const dtClient = await (0, dynatrace_clients_1.
|
|
406
|
+
const dtClient = await (0, dynatrace_clients_1.createDtHttpClient)(dtEnvironment, scopesBase.concat('environment-api:entities:read', 'settings:objects:read'), oauthClientId, oauthClientSecret, dtPlatformToken);
|
|
315
407
|
console.error(`Fetching ownership for ${entityIds}`);
|
|
316
408
|
const ownershipInformation = await (0, get_ownership_information_1.getOwnershipInformation)(dtClient, entityIds);
|
|
317
409
|
console.error(`Done!`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dynatrace-oss/dynatrace-mcp-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0-rc.2",
|
|
4
4
|
"description": "Model Context Protocol (MCP) server for Dynatrace",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"Dynatrace",
|
|
@@ -38,6 +38,8 @@
|
|
|
38
38
|
"prepare": "npm run build",
|
|
39
39
|
"watch": "tsc --watch",
|
|
40
40
|
"test": "jest",
|
|
41
|
+
"test:unit": "jest --selectProjects unit",
|
|
42
|
+
"test:integration": "jest --selectProjects integration --runInBand",
|
|
41
43
|
"prettier": "prettier --check .",
|
|
42
44
|
"prettier:fix": "prettier --write ."
|
|
43
45
|
},
|