@dynatrace-oss/dynatrace-mcp-server 0.9.0 → 0.9.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.
|
@@ -80,7 +80,8 @@ const createOAuthClientCredentialsHttpClient = async (environmentUrl, scopes, cl
|
|
|
80
80
|
};
|
|
81
81
|
/** Create an OAuth Client using authorization code flow (interactive authentication)
|
|
82
82
|
* This starts a local HTTP server to handle the OAuth redirect and requires user interaction.
|
|
83
|
-
* Implements token
|
|
83
|
+
* Implements an in-memory token cache (not persisted to disk). After every server restart a new
|
|
84
|
+
* authentication flow (or token refresh) may be required.
|
|
84
85
|
* 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
|
*/
|
|
86
87
|
const createOAuthAuthCodeFlowHttpClient = async (environmentUrl, scopes, clientId) => {
|
|
@@ -1,149 +1,54 @@
|
|
|
1
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
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
-
exports.globalTokenCache = exports.
|
|
37
|
-
const fs = __importStar(require("fs"));
|
|
38
|
-
const path = __importStar(require("path"));
|
|
3
|
+
exports.globalTokenCache = exports.InMemoryTokenCache = void 0;
|
|
39
4
|
/**
|
|
40
|
-
*
|
|
41
|
-
*
|
|
5
|
+
* In-memory token cache implementation (no persistence across process restarts).
|
|
6
|
+
* The previous implementation stored tokens on disk in `.dt-mcp/token.json` – this has been
|
|
7
|
+
* intentionally removed to avoid writing credentials to the local filesystem. A new login /
|
|
8
|
+
* OAuth authorization code flow (or token retrieval) will be required after every server restart.
|
|
42
9
|
*/
|
|
43
|
-
class
|
|
44
|
-
tokenFilePath;
|
|
10
|
+
class InMemoryTokenCache {
|
|
45
11
|
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
12
|
/**
|
|
98
13
|
* Retrieves the cached token (ignores scopes since we use a global token)
|
|
99
14
|
*/
|
|
100
15
|
getToken(scopes) {
|
|
101
|
-
//
|
|
16
|
+
// Scopes parameter ignored – single global token covers all requested scopes.
|
|
102
17
|
return this.token;
|
|
103
18
|
}
|
|
104
19
|
/**
|
|
105
20
|
* Stores the global token in the cache and persists it to file
|
|
106
21
|
*/
|
|
107
22
|
setToken(scopes, token) {
|
|
108
|
-
// We ignore the scopes parameter since we use a single token with all scopes
|
|
109
23
|
this.token = {
|
|
110
24
|
access_token: token.access_token,
|
|
111
25
|
refresh_token: token.refresh_token,
|
|
112
26
|
expires_at: token.expires_in ? Date.now() + token.expires_in * 1000 : undefined,
|
|
113
|
-
scopes: [...scopes],
|
|
27
|
+
scopes: [...scopes],
|
|
114
28
|
};
|
|
115
|
-
this.saveToken();
|
|
116
29
|
}
|
|
117
30
|
/**
|
|
118
31
|
* Removes the cached token and deletes the file
|
|
119
32
|
*/
|
|
120
33
|
clearToken(scopes) {
|
|
121
|
-
// We ignore the scopes parameter since we use a single global token
|
|
122
34
|
this.token = null;
|
|
123
|
-
this.saveToken();
|
|
124
35
|
}
|
|
125
36
|
/**
|
|
126
37
|
* Checks if the token exists and is still valid (not expired)
|
|
127
38
|
*/
|
|
128
39
|
isTokenValid(scopes) {
|
|
129
40
|
// 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`);
|
|
41
|
+
if (!this.token)
|
|
132
42
|
return false;
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
if (!this.token.expires_at) {
|
|
136
|
-
console.error(`🔍 Token validation: Token has no expiration, assuming valid`);
|
|
137
|
-
return true;
|
|
138
|
-
}
|
|
43
|
+
if (!this.token.expires_at)
|
|
44
|
+
return true; // treat as non-expiring
|
|
139
45
|
// Add a 30-second buffer to avoid using tokens that are about to expire
|
|
140
46
|
const bufferMs = 30 * 1000; // 30 seconds
|
|
141
47
|
const now = Date.now();
|
|
142
48
|
const expiresAt = this.token.expires_at;
|
|
143
|
-
|
|
144
|
-
return isValid;
|
|
49
|
+
return now + bufferMs < expiresAt;
|
|
145
50
|
}
|
|
146
51
|
}
|
|
147
|
-
exports.
|
|
148
|
-
// Global token cache instance -
|
|
149
|
-
exports.globalTokenCache = new
|
|
52
|
+
exports.InMemoryTokenCache = InMemoryTokenCache;
|
|
53
|
+
// Global token cache instance - In-memory only
|
|
54
|
+
exports.globalTokenCache = new InMemoryTokenCache();
|
package/dist/index.js
CHANGED
|
@@ -27,6 +27,7 @@ const getDynatraceEnv_1 = require("./getDynatraceEnv");
|
|
|
27
27
|
const telemetry_openkit_1 = require("./utils/telemetry-openkit");
|
|
28
28
|
const dynatrace_entity_types_1 = require("./utils/dynatrace-entity-types");
|
|
29
29
|
const grail_budget_tracker_1 = require("./utils/grail-budget-tracker");
|
|
30
|
+
const dynatrace_connection_utils_1 = require("./utils/dynatrace-connection-utils");
|
|
30
31
|
// Load environment variables from .env file if available, and suppress warnings/logging to stdio
|
|
31
32
|
// as it breaks MCP communication when using stdio transport
|
|
32
33
|
const dotEnvOutput = (0, dotenv_1.config)({ quiet: true });
|
|
@@ -42,6 +43,7 @@ else {
|
|
|
42
43
|
console.error(`.env file loaded successfully - loaded ${dotEnvOutput.parsed ? Object.keys(dotEnvOutput.parsed).length : 0} environment variables: ${Object.keys(dotEnvOutput.parsed || {}).join(', ')}`);
|
|
43
44
|
}
|
|
44
45
|
const DT_MCP_AUTH_CODE_FLOW_OAUTH_CLIENT_ID = 'dt0s08.dt-app-local'; // ToDo: Register our own oauth client
|
|
46
|
+
// Base Scopes for MCP Server tools
|
|
45
47
|
let scopesBase = [
|
|
46
48
|
'app-engine:apps:run', // needed for environmentInformationClient
|
|
47
49
|
'app-engine:functions:run', // needed for environmentInformationClient
|
|
@@ -53,7 +55,8 @@ const allRequiredScopes = scopesBase.concat([
|
|
|
53
55
|
'storage:events:read', // Read events from Grail
|
|
54
56
|
'storage:buckets:read', // Read all system data stored on Grail
|
|
55
57
|
'storage:security.events:read', // Read Security events from Grail
|
|
56
|
-
'storage:entities:read', // Read Entities
|
|
58
|
+
'storage:entities:read', // Read classic Entities
|
|
59
|
+
'storage:smartscape:read', // Read Smartscape Entities from Grail
|
|
57
60
|
'storage:logs:read', // Read logs for reliability guardian validations
|
|
58
61
|
'storage:metrics:read', // Read metrics for reliability guardian validations
|
|
59
62
|
'storage:bizevents:read', // Read bizevents for reliability guardian validations
|
|
@@ -74,57 +77,6 @@ const allRequiredScopes = scopesBase.concat([
|
|
|
74
77
|
// Communication scopes
|
|
75
78
|
'email:emails:send', // Send emails
|
|
76
79
|
]);
|
|
77
|
-
/**
|
|
78
|
-
* Performs a connection test to the Dynatrace environment.
|
|
79
|
-
* Throws an error if the connection or authentication fails.
|
|
80
|
-
*/
|
|
81
|
-
async function testDynatraceConnection(dtEnvironment, oauthClientId, oauthClientSecret, dtPlatformToken) {
|
|
82
|
-
const dtClient = await (0, dynatrace_clients_1.createDtHttpClient)(dtEnvironment, oauthClientId && !oauthClientSecret ? allRequiredScopes : scopesBase, oauthClientId, oauthClientSecret, dtPlatformToken);
|
|
83
|
-
const environmentInformationClient = new client_platform_management_service_1.EnvironmentInformationClient(dtClient);
|
|
84
|
-
// This call will fail if authentication is incorrect.
|
|
85
|
-
await environmentInformationClient.getEnvironmentInformation();
|
|
86
|
-
}
|
|
87
|
-
function handleClientRequestError(error) {
|
|
88
|
-
let additionalErrorInformation = '';
|
|
89
|
-
if (error.response.status === 403) {
|
|
90
|
-
additionalErrorInformation =
|
|
91
|
-
'Note: Your user or service-user is most likely lacking the necessary permissions/scopes for this API Call.';
|
|
92
|
-
}
|
|
93
|
-
return `Client Request Error: ${error.message} with HTTP status: ${error.response.status}. ${additionalErrorInformation} (body: ${JSON.stringify(error.body)})`;
|
|
94
|
-
}
|
|
95
|
-
/**
|
|
96
|
-
* Try to connect to Dynatrace environment with retries and exponential backoff.
|
|
97
|
-
*/
|
|
98
|
-
async function retryTestDynatraceConnection(dtEnvironment, oauthClientId, oauthClientSecret, dtPlatformToken) {
|
|
99
|
-
let retryCount = 0;
|
|
100
|
-
const maxRetries = 3; // Max retries
|
|
101
|
-
const delayMs = 2000; // Initial delay of 2 seconds
|
|
102
|
-
while (true) {
|
|
103
|
-
try {
|
|
104
|
-
console.error(`Testing connection to Dynatrace environment: ${dtEnvironment}... (Attempt ${retryCount + 1} of ${maxRetries})`);
|
|
105
|
-
await testDynatraceConnection(dtEnvironment, oauthClientId, oauthClientSecret, dtPlatformToken);
|
|
106
|
-
console.error(`Successfully connected to the Dynatrace environment at ${dtEnvironment}.`);
|
|
107
|
-
break;
|
|
108
|
-
}
|
|
109
|
-
catch (error) {
|
|
110
|
-
console.error(`Error: Could not connect to the Dynatrace environment at ${dtEnvironment}.`);
|
|
111
|
-
if ((0, shared_errors_1.isClientRequestError)(error)) {
|
|
112
|
-
console.error(handleClientRequestError(error));
|
|
113
|
-
}
|
|
114
|
-
else {
|
|
115
|
-
console.error(`Error: ${error.message}`);
|
|
116
|
-
}
|
|
117
|
-
retryCount++;
|
|
118
|
-
if (retryCount >= maxRetries) {
|
|
119
|
-
console.error(`Fatal: Maximum number of connection retries (${maxRetries}) exceeded. Exiting.`);
|
|
120
|
-
throw new Error(`Failed to connect to Dynatrace environment ${dtEnvironment} after ${maxRetries} attempts. Most likely your configuration is incorrect. Last error: ${error.message}`, { cause: error });
|
|
121
|
-
}
|
|
122
|
-
const delay = Math.pow(2, retryCount) * delayMs; // Exponential backoff
|
|
123
|
-
console.error(`Retrying in ${delay / 1000} seconds...`);
|
|
124
|
-
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
80
|
const main = async () => {
|
|
129
81
|
console.error(`Initializing Dynatrace MCP Server v${(0, version_1.getPackageJsonVersion)()}...`);
|
|
130
82
|
// read Environment variables
|
|
@@ -144,20 +96,6 @@ const main = async () => {
|
|
|
144
96
|
console.error('No OAuth credentials or platform token provided - switching to OAuth authorization code flow.');
|
|
145
97
|
oauthClientId = DT_MCP_AUTH_CODE_FLOW_OAUTH_CLIENT_ID; // Default OAuth client ID for auth code flow
|
|
146
98
|
}
|
|
147
|
-
// Test connection on startup
|
|
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
|
|
153
|
-
await retryTestDynatraceConnection(dtEnvironment, oauthClientId, oauthClientSecret, dtPlatformToken);
|
|
154
|
-
}
|
|
155
|
-
catch (err) {
|
|
156
|
-
console.error(err.message);
|
|
157
|
-
process.exit(2);
|
|
158
|
-
}
|
|
159
|
-
// Ready to start the server
|
|
160
|
-
console.error(`Starting Dynatrace MCP Server v${(0, version_1.getPackageJsonVersion)()}...`);
|
|
161
99
|
// Initialize usage tracking
|
|
162
100
|
const telemetry = (0, telemetry_openkit_1.createTelemetry)();
|
|
163
101
|
await telemetry.trackMcpServerStart();
|
|
@@ -189,6 +127,51 @@ const main = async () => {
|
|
|
189
127
|
return await (0, dynatrace_clients_1.createDtHttpClient)(dtEnvironment, oauthClientId && !oauthClientSecret ? allRequiredScopes : scopes, // Always use all scopes for maximum reusability
|
|
190
128
|
oauthClientId, oauthClientSecret, dtPlatformToken);
|
|
191
129
|
};
|
|
130
|
+
// Try to establish a Dynatrace connection upfront, to see if everything is configured properly
|
|
131
|
+
console.error(`Testing connection to Dynatrace environment: ${dtEnvironment}...`);
|
|
132
|
+
// First, we will try a simple "fetch" to connect to dtEnvironment, without authentication
|
|
133
|
+
// This should help to see if DNS lookup works, TCP connection can be established, and TLS handshake works
|
|
134
|
+
try {
|
|
135
|
+
const response = await fetch(`${dtEnvironment}`).then((response) => response.text());
|
|
136
|
+
// check response
|
|
137
|
+
if (response && response.length > 0) {
|
|
138
|
+
if (response.includes('Authentication required')) {
|
|
139
|
+
// all good - we reached the environment and authentication is required, which is going to be the next step
|
|
140
|
+
}
|
|
141
|
+
else {
|
|
142
|
+
console.error(`⚠️ Tried to contact ${dtEnvironment}, got the following response: ${response}`);
|
|
143
|
+
// Note: We won't error out yet, but this information could already be helpful for troubleshooting
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
throw new Error('No response received');
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
catch (error) {
|
|
151
|
+
console.error(`❌ Failed to connect to Dynatrace environment ${dtEnvironment}:`, error.message);
|
|
152
|
+
console.error(error);
|
|
153
|
+
process.exit(3);
|
|
154
|
+
}
|
|
155
|
+
// Second, we will try with proper authentication
|
|
156
|
+
try {
|
|
157
|
+
const dtClient = await createAuthenticatedHttpClient(scopesBase);
|
|
158
|
+
const environmentInformationClient = new client_platform_management_service_1.EnvironmentInformationClient(dtClient);
|
|
159
|
+
await environmentInformationClient.getEnvironmentInformation();
|
|
160
|
+
console.error(`✅ Successfully connected to the Dynatrace environment at ${dtEnvironment}.`);
|
|
161
|
+
}
|
|
162
|
+
catch (error) {
|
|
163
|
+
if ((0, shared_errors_1.isClientRequestError)(error)) {
|
|
164
|
+
console.error(`❌ Failed to connect to Dynatrace environment ${dtEnvironment}:`, (0, dynatrace_connection_utils_1.handleClientRequestError)(error));
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
console.error(`❌ Failed to connect to Dynatrace environment ${dtEnvironment}:`, error.message);
|
|
168
|
+
// Logging more exhaustive error details for troubleshooting
|
|
169
|
+
console.error(error);
|
|
170
|
+
}
|
|
171
|
+
process.exit(2);
|
|
172
|
+
}
|
|
173
|
+
// Ready to start the server
|
|
174
|
+
console.error(`Starting Dynatrace MCP Server v${(0, version_1.getPackageJsonVersion)()}...`);
|
|
192
175
|
// quick abstraction/wrapper to make it easier for tools to reply text instead of JSON
|
|
193
176
|
const tool = (name, description, paramsSchema, annotations, cb) => {
|
|
194
177
|
const wrappedCb = async (args) => {
|
|
@@ -210,11 +193,11 @@ const main = async () => {
|
|
|
210
193
|
// check if it's an error originating from the Dynatrace SDK / API Gateway and provide an appropriate message to the user
|
|
211
194
|
if ((0, shared_errors_1.isClientRequestError)(error)) {
|
|
212
195
|
return {
|
|
213
|
-
content: [{ type: 'text', text: handleClientRequestError(error) }],
|
|
196
|
+
content: [{ type: 'text', text: (0, dynatrace_connection_utils_1.handleClientRequestError)(error) }],
|
|
214
197
|
isError: true,
|
|
215
198
|
};
|
|
216
199
|
}
|
|
217
|
-
// else: We don't know what kind of error happened - best
|
|
200
|
+
// else: We don't know what kind of error happened - best case we can log the error and provide error.message as a tool response
|
|
218
201
|
console.log(error);
|
|
219
202
|
return {
|
|
220
203
|
content: [{ type: 'text', text: `Error: ${error.message}` }],
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.handleClientRequestError = handleClientRequestError;
|
|
4
|
+
function handleClientRequestError(error) {
|
|
5
|
+
let additionalErrorInformation = '';
|
|
6
|
+
if (error.response.status === 403) {
|
|
7
|
+
additionalErrorInformation =
|
|
8
|
+
'Note: Your user or service-user is most likely lacking the necessary permissions/scopes for this API Call.';
|
|
9
|
+
}
|
|
10
|
+
return `Client Request Error: ${error.message} with HTTP status: ${error.response.status}. ${additionalErrorInformation} (body: ${JSON.stringify(error.body)})`;
|
|
11
|
+
}
|