@dynatrace-oss/dynatrace-mcp-server 1.5.0 → 1.5.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.
@@ -78,6 +78,15 @@ const createOAuthClientCredentialsHttpClient = async (environmentUrl, scopes, cl
78
78
  // now that we have the access token, we can just use a plain bearer token client
79
79
  return createBearerTokenHttpClient(environmentUrl, tokenResponse.access_token);
80
80
  };
81
+ /**
82
+ * Shared promise for an in-progress token refresh (prevents concurrent refresh races).
83
+ * When multiple callers find an expired token simultaneously, only the first starts a refresh;
84
+ * all others await the same promise so the refresh token is only consumed once.
85
+ *
86
+ * Note: this relies on Node.js's single-threaded event loop — no concurrent synchronous
87
+ * access can occur between the null-check and the assignment below.
88
+ */
89
+ let ongoingRefreshPromise = null;
81
90
  /** Create an OAuth Client using authorization code flow (interactive authentication)
82
91
  * This starts a local HTTP server to handle the OAuth redirect and requires user interaction.
83
92
  * Implements an in-memory token cache (not persisted to disk). After every server restart a new
@@ -97,13 +106,22 @@ const createOAuthAuthCodeFlowHttpClient = async (environmentUrl, scopes, clientI
97
106
  // just use the cached token as a bearer token
98
107
  return createBearerTokenHttpClient(environmentUrl, cachedToken.access_token);
99
108
  }
100
- // If we have an expired token that can be refreshed, refresh it
109
+ // If we have an expired token that can be refreshed, refresh it.
110
+ // Use a single shared promise to deduplicate concurrent refresh attempts so the refresh token
111
+ // is only consumed once (OAuth refresh tokens are rotated/invalidated on use).
101
112
  if (cachedToken && cachedToken.refresh_token && !isValid) {
102
113
  const expiresIn = cachedToken.expires_at ? Math.round((cachedToken.expires_at - Date.now()) / 1000) : 'never';
103
- console.error(`🔍 Auth-Code-Flow: Found expired cached token (expires in ${expiresIn}s), attempting refresh...`);
114
+ if (!ongoingRefreshPromise) {
115
+ console.error(`🔍 Auth-Code-Flow: Found expired cached token (expires in ${expiresIn}s), attempting refresh...`);
116
+ ongoingRefreshPromise = (0, dynatrace_oauth_auth_code_flow_1.refreshAccessToken)(ssoBaseURL, clientId, cachedToken.refresh_token, scopes).finally(() => {
117
+ ongoingRefreshPromise = null;
118
+ });
119
+ }
120
+ else {
121
+ console.error(`🔄 Token refresh already in progress, waiting for it to complete...`);
122
+ }
104
123
  try {
105
- console.error(`🔄 Attempting to refresh expired access token...`);
106
- const tokenResponse = await (0, dynatrace_oauth_auth_code_flow_1.refreshAccessToken)(ssoBaseURL, clientId, cachedToken.refresh_token, scopes);
124
+ const tokenResponse = await ongoingRefreshPromise;
107
125
  if (tokenResponse.access_token && !tokenResponse.error) {
108
126
  console.error(`✅ Successfully refreshed access token!`);
109
127
  // Update the cache with the new token
@@ -18,6 +18,7 @@ global.fetch = jest.fn();
18
18
  const mockPlatformHttpClient = http_client_1.PlatformHttpClient;
19
19
  const mockGetSSOUrl = get_sso_url_1.getSSOUrl;
20
20
  const mockPerformOAuthAuthorizationCodeFlow = dynatrace_oauth_auth_code_flow_1.performOAuthAuthorizationCodeFlow;
21
+ const mockRefreshAccessToken = dynatrace_oauth_auth_code_flow_1.refreshAccessToken;
21
22
  const mockGlobalTokenCache = token_cache_1.globalTokenCache;
22
23
  const mockFetch = global.fetch;
23
24
  describe('dynatrace-clients', () => {
@@ -144,6 +145,36 @@ describe('dynatrace-clients', () => {
144
145
  expect(result).toBeInstanceOf(http_client_1.PlatformHttpClient);
145
146
  });
146
147
  });
148
+ describe('with OAuth auth code flow (clientId only)', () => {
149
+ const clientId = 'test-client-id';
150
+ it('should deduplicate concurrent token refresh attempts', async () => {
151
+ const expiredCachedToken = {
152
+ access_token: 'expired-access-token',
153
+ refresh_token: 'valid-refresh-token',
154
+ expires_at: Date.now() - 60000, // expired 1 minute ago
155
+ scopes,
156
+ };
157
+ const newTokenResponse = {
158
+ access_token: 'new-access-token',
159
+ token_type: 'Bearer',
160
+ expires_in: 3600,
161
+ refresh_token: 'new-refresh-token',
162
+ };
163
+ mockGlobalTokenCache.getToken.mockReturnValue(expiredCachedToken);
164
+ mockGlobalTokenCache.isTokenValid.mockReturnValue(false);
165
+ // Simulate a slow refresh (10ms) so concurrent callers have time to pile up
166
+ mockRefreshAccessToken.mockImplementation(() => new Promise((resolve) => setTimeout(() => resolve(newTokenResponse), 10)));
167
+ // Initiate two concurrent calls – both see the same expired token
168
+ const [client1, client2] = await Promise.all([
169
+ (0, dynatrace_clients_1.createDtHttpClient)(environmentUrl, scopes, clientId),
170
+ (0, dynatrace_clients_1.createDtHttpClient)(environmentUrl, scopes, clientId),
171
+ ]);
172
+ // The refresh must only have been attempted once (no race on the refresh token)
173
+ expect(mockRefreshAccessToken).toHaveBeenCalledTimes(1);
174
+ expect(client1).toBeInstanceOf(http_client_1.PlatformHttpClient);
175
+ expect(client2).toBeInstanceOf(http_client_1.PlatformHttpClient);
176
+ });
177
+ });
147
178
  });
148
179
  describe('requestToken function (indirectly tested)', () => {
149
180
  it('should handle fetch errors gracefully', async () => {