Skip to content

Commit 3cc0dd6

Browse files
Merge remote-tracking branch 'origin/main' into mgd-13107-snyk-does-not-have-authorization-in-github-actions
2 parents 1cd5921 + 239ecdc commit 3cc0dd6

6 files changed

Lines changed: 373 additions & 102 deletions

File tree

CHANGELOG.md

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,22 @@
11
# @dynatrace-oss/dynatrace-managed-mcp
22

3-
## Unreleased Changes
3+
## Unreleased
44

5+
### Breaking changes
6+
7+
- Security (HTTP mode): unauthenticated requests are now rejected. A request that omits the `X-Dynatrace-Tokens` header, or supplies no token that is valid on its environment, receives HTTP 401 before any MCP server or tool is created. Previously such a request could complete the MCP `initialize` handshake and call tools — including `get_environments_info`, which disclosed configured environment aliases and URLs.
8+
- Removed the `dynatrace_managed_check_config_errors` tool. The startup configuration errors it reported are redundant with `get_environments_info`; any automation referencing this tool by name must be updated.
9+
- The server no longer starts when any configured environment is invalid — configuration errors now fail startup instead of being skipped. Fix all environment configuration errors before starting.
10+
11+
### Changes
12+
13+
- Added `DT_MCP_TOKEN_VALIDATION_TTL_MS` (default `60000`) to control how long per-caller token-validation results are cached.
14+
- Updated `fast-uri` to 3.1.4.
15+
16+
### Fixes
17+
18+
- Per-request tokens are validated against the cluster (`POST /api/v2/apiTokens/lookup`) before tools are exposed. Validation results are cached per caller (keyed by a hash of the supplied tokens) and concurrent requests share a single validation, so the cluster is not probed on every request.
19+
- `get_environments_info` now reports only on environments the caller supplied a token for, and returns an identical "invalid token" message whether an alias is unknown or its token is invalid — so callers can no longer enumerate which environments are configured.
520
- Improved `get_environments_info` in stdio mode: now uses cached startup validation results (version, validity, error) instead of re-probing live on every call, eliminating redundant network requests. The cluster version and minimum version check are now displayed from the cached result.
621
- Improved rate-limiting key stability: `deriveUserKey` now normalises the `X-Dynatrace-Tokens` header (sorts aliases, strips whitespace) so equivalent token sets produce the same rate-limit bucket regardless of header ordering.
722

package-lock.json

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/authentication/managed-auth-client.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,11 @@ export interface ManagedAuthClientParams {
2929
minimum_version: string;
3030
}
3131

32+
interface ApiToken {
33+
enabled?: boolean;
34+
scopes?: string[];
35+
}
36+
3237
/** Thrown when a request targets an environment for which the caller supplied no token. */
3338
export class MissingTokenError extends Error {
3439
constructor(public readonly alias: string) {
@@ -75,6 +80,20 @@ export class ManagedAuthClient {
7580
return { Authorization: `Api-Token ${token}` };
7681
}
7782

83+
async validateAPIToken(token: string): Promise<boolean> {
84+
try {
85+
const response = await this.httpClient.post<ApiToken>(
86+
'/api/v2/apiTokens/lookup',
87+
{ token },
88+
{ headers: this.authHeader(token) },
89+
);
90+
return !!(response.data?.enabled && response.data?.scopes && response.data.scopes.length > 0);
91+
} catch (error) {
92+
logger.warn(`[Alias: ${this.alias}] API token failed validation`, { error });
93+
return false;
94+
}
95+
}
96+
7897
async validateConnection(token: string): Promise<boolean> {
7998
try {
8099
// Try cluster version endpoint for Managed environments
@@ -255,6 +274,11 @@ export class ManagedAuthClientManager {
255274
return this.tokens.get(alias);
256275
}
257276

277+
/** Aliases the caller actually supplied a token for (their own input, not the configured set). */
278+
suppliedAliases(): string[] {
279+
return [...this.tokens.keys()];
280+
}
281+
258282
async makeRequests<T>(
259283
endpoint: string,
260284
params: Record<string, string | number | boolean | undefined>,

src/index.ts

Lines changed: 89 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,53 @@ logger.info('Starting Dynatrace Managed MCP');
3939
// In stdio mode there is a single user key, which reproduces the previous single-bucket behavior.
4040
const rateLimiter = new RateLimiter();
4141

42+
// Caches the in-flight Promise so a burst of concurrent requests shares one lookup.
43+
const tokenValidationCache = new Map<string, { expiresAt: number; result: Promise<boolean> }>();
44+
const TOKEN_VALIDATION_TTL_MS = (() => {
45+
const parsed = Number(process.env.DT_MCP_TOKEN_VALIDATION_TTL_MS);
46+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : 60 * 1000;
47+
})();
48+
49+
const hasValidSuppliedTokens = (
50+
clients: ManagedAuthClient[],
51+
tokenMap: Map<string, string>,
52+
userKey: string,
53+
): Promise<boolean> => {
54+
const now = Date.now();
55+
56+
const cached = tokenValidationCache.get(userKey);
57+
if (cached !== undefined) {
58+
if (cached.expiresAt > now) {
59+
return cached.result;
60+
} else {
61+
tokenValidationCache.delete(userKey);
62+
}
63+
}
64+
65+
const supplied = clients
66+
.map((client) => ({ client, token: tokenMap.get(client.alias) }))
67+
.filter((entry): entry is { client: ManagedAuthClient; token: string } => entry.token !== undefined);
68+
if (supplied.length === 0) {
69+
return Promise.resolve(false);
70+
}
71+
72+
const result = (async (): Promise<boolean> => {
73+
const results = await Promise.all(supplied.map(({ client, token }) => client.validateAPIToken(token)));
74+
return results.some(Boolean);
75+
})();
76+
77+
tokenValidationCache.set(userKey, { expiresAt: now + TOKEN_VALIDATION_TTL_MS, result });
78+
result
79+
.then((valid) => {
80+
if (!valid) {
81+
tokenValidationCache.delete(userKey);
82+
}
83+
})
84+
.catch(() => tokenValidationCache.delete(userKey));
85+
86+
return result;
87+
};
88+
4289
const main = async () => {
4390
logger.info(`Initializing Dynatrace Managed MCP Server v${getPackageJsonVersion()}...`);
4491

@@ -56,7 +103,7 @@ const main = async () => {
56103

57104
const options = program.opts();
58105
const httpMode = options.http || options.server;
59-
const httpPort = parseInt(options.port, 10);
106+
const httpPort = Number(options.port);
60107
const host = options.host || '127.0.0.1';
61108

62109
// Read Managed environment configuration. In HTTP mode tokens are supplied per request
@@ -70,6 +117,8 @@ const main = async () => {
70117
if (initErrors.length > 0) {
71118
logger.error('Failed to get managed environments configurations: ', { error: initErrors });
72119
console.error('Failed to get managed environments configurations: ', { error: initErrors });
120+
await flushLogger();
121+
process.exit(1);
73122
}
74123

75124
if (initConfigs.length === 0) {
@@ -118,7 +167,7 @@ const main = async () => {
118167
// Factory: creates a new McpServer with all tools registered, bound to this caller's tokens.
119168
// Must be called per-request in stateless HTTP mode (the SDK forbids connecting
120169
// the same McpServer instance to more than one transport).
121-
const createConfiguredMcpServer = (tokenMap: Map<string, string>, userKey: string) => {
170+
const createConfiguredMcpServer = (tokenMap: Map<string, string>, userKey: string): McpServer => {
122171
// Per-request auth router + API clients.
123172
const authClientManager = new ManagedAuthClientManager(allClients, validClients, validAliases, tokenMap);
124173
const metricsClient = new MetricsApiClient(authClientManager);
@@ -256,36 +305,30 @@ const main = async () => {
256305
// HTTP server mode (Stateless)
257306
if (httpMode) {
258307
const httpServer = createServer(async (req: IncomingMessage, res: ServerResponse) => {
259-
// Parse request body for POST requests
260-
let body: unknown;
261-
// Create a new Stateless HTTP Transport
262-
// enableJsonResponse: true returns application/json instead of keeping SSE streams open,
263-
// which is required for MCP clients that don't support persistent SSE connections (e.g. Copilot CLI).
264-
const httpTransport = new StreamableHTTPServerTransport({
265-
sessionIdGenerator: undefined, // No Session ID needed
266-
enableJsonResponse: true,
267-
});
268-
269-
// Per-request tokens come from the X-Dynatrace-Tokens header (alias=token;alias=token).
270308
const tokenHeader = req.headers['x-dynatrace-tokens'];
271309
const tokenMap = parseTokenHeader(tokenHeader);
272310
const userKey = deriveUserKey(Array.isArray(tokenHeader) ? tokenHeader.join(';') : tokenHeader);
273311

274-
// Create a fresh McpServer per request (stateless HTTP requirement)
275-
const server = createConfiguredMcpServer(tokenMap, userKey);
276-
277-
res.on('close', () => {
278-
// close transport and server, but not the httpServer itself
279-
httpTransport.close();
280-
server.close();
281-
});
282-
283-
// Connecting MCP-server to HTTP transport
284-
await server.connect(httpTransport);
312+
// Validate every request: a stateless MCP connection spans several requests, and gating only
313+
// the initialize handshake leaves the SSE GET to hang the client until timeout.
314+
if (!(await hasValidSuppliedTokens(allClients, tokenMap, userKey))) {
315+
res.writeHead(401, { 'Content-Type': 'application/json' });
316+
res.end(
317+
JSON.stringify({
318+
jsonrpc: '2.0',
319+
id: null,
320+
error: { code: -32000, message: 'Unauthorized: no valid Dynatrace token supplied' },
321+
}),
322+
);
323+
return;
324+
}
285325

286-
// Handle POST Requests for this endpoint
326+
let body: unknown;
287327
if (req.method === 'POST') {
288-
const maxBodySize = parseInt(process.env.DT_MCP_MAX_BODY_SIZE ?? String(1 * 1024 * 1024), 10); // default 1MB
328+
const maxBodySize = (() => {
329+
const parsed = Number(process.env.DT_MCP_MAX_BODY_SIZE);
330+
return Number.isFinite(parsed) && parsed > 0 ? parsed : 1024 * 1024;
331+
})(); // default 1MB
289332
const chunks: Buffer[] = [];
290333
let totalSize = 0;
291334
let tooLarge = false;
@@ -315,13 +358,32 @@ const main = async () => {
315358
}
316359
}
317360

361+
// Create a new Stateless HTTP Transport
362+
// enableJsonResponse: true returns application/json instead of keeping SSE streams open,
363+
// which is required for MCP clients that don't support persistent SSE connections (e.g. Copilot CLI).
364+
const httpTransport = new StreamableHTTPServerTransport({
365+
sessionIdGenerator: undefined, // No Session ID needed
366+
enableJsonResponse: true,
367+
});
368+
369+
// Create a fresh McpServer per request (stateless HTTP requirement)
370+
const server = createConfiguredMcpServer(tokenMap, userKey);
371+
372+
res.on('close', () => {
373+
// close transport and server, but not the httpServer itself
374+
httpTransport.close();
375+
server.close();
376+
});
377+
378+
// Connecting MCP-server to HTTP transport
379+
await server.connect(httpTransport);
380+
318381
await httpTransport.handleRequest(req, res, body);
319382
});
320383

321384
// Start HTTP Server on the specified host and port
322385
httpServer.listen(httpPort, host, () => {
323386
logger.info(`Dynatrace Managed MCP Server running on HTTP at http://${host}:${httpPort}`);
324-
console.error(`Dynatrace Managed MCP Server running on HTTP at http://${host}:${httpPort}`);
325387
});
326388

327389
// Handle graceful shutdown for http server mode

0 commit comments

Comments
 (0)