#!/usr/bin/env node
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { CallToolResult, ToolAnnotations } from '@modelcontextprotocol/sdk/types.js';
import { createServer, IncomingMessage, ServerResponse } from 'node:http';
import { Command } from 'commander';
import { z, ZodRawShape, ZodTypeAny } from 'zod';
import { getPackageJsonVersion } from './utils/version';
import { ManagedAuthClientManager } from './authentication/managed-auth-client';
import { getManagedEnvironmentConfigs, validateEnvironments } from './utils/environment';
import { createTelemetry } from './utils/telemetry-openkit';
import { MetricsApiClient } from './capabilities/metrics-api';
import { LogsApiClient } from './capabilities/logs-api';
import { EventsApiClient } from './capabilities/events-api';
import { EntitiesApiClient } from './capabilities/entities-api';
import { ProblemsApiClient } from './capabilities/problems-api';
import { SecurityApiClient } from './capabilities/security-api';
import { SloApiClient } from './capabilities/slo-api';
import { getRateLimitConfig } from './utils/rate-limit';
// Import logger after environment is loaded
import { logger, flushLogger } from './utils/logger';
logger.info('Starting Dynatrace Managed MCP');
// Required API scopes for Managed deployment
const MANAGED_API_SCOPES = [
'DataExport', // Read metrics and topology
'ReadConfig', // Read configuration and cluster version
'ReadSyntheticData', // Read synthetic monitoring data
'ReadLogContent', // Read log content
'ReadEvents', // Read events
'ReadProblems', // Read problems and root cause analysis
'ReadSecurityProblems', // Read security problems
'ReadSLO', // Read Service Level Objectives
];
// Rate limiting configuration (configurable via DT_MCP_RATE_LIMIT_MAX_CALLS and DT_MCP_RATE_LIMIT_WINDOW_MS)
const { maxCalls: RATE_LIMIT_MAX_CALLS, windowMs: RATE_LIMIT_WINDOW_MS } = getRateLimitConfig();
// Rate limiting state: store timestamps of tool calls
let toolCallTimestamps: number[] = [];
const main = async () => {
logger.info(`Initializing Dynatrace Managed MCP Server v${getPackageJsonVersion()}...`);
// Read Managed environment configuration
const managedConfigs = getManagedEnvironmentConfigs();
const validatedConfigs = validateEnvironments(managedConfigs);
const initErrors = validatedConfigs['errors'];
const initConfigs = validatedConfigs['valid_configs'];
if (initErrors.length > 0) {
logger.error('Failed to get managed environments configurations: ', { error: initErrors });
console.error('Failed to get managed environments configurations: ', { error: initErrors });
}
if (initConfigs.length === 0) {
logger.error('No valid environments found, stopping.');
console.error('No valid environments found, stopping.');
await flushLogger();
process.exit(1);
}
const authClientManager = new ManagedAuthClientManager(initConfigs);
await authClientManager.isConfigured();
// Initialize API clients
const metricsClient = new MetricsApiClient(authClientManager);
const logsClient = new LogsApiClient(authClientManager);
const eventsClient = new EventsApiClient(authClientManager);
const entitiesClient = new EntitiesApiClient(authClientManager);
const problemsClient = new ProblemsApiClient(authClientManager);
const securityClient = new SecurityApiClient(authClientManager);
const sloClient = new SloApiClient(authClientManager);
// Initialize usage tracking
const telemetry = createTelemetry();
await telemetry.trackMcpServerStart();
// Create a shutdown handler that takes shutdown operations as parameters
const shutdownHandler = (...shutdownOps: Array<() => void | Promise<void>>) => {
return async () => {
logger.info('Shutting down MCP server...');
for (const op of shutdownOps) {
await op();
}
await flushLogger();
process.exit(0);
};
};
// Factory: creates a new McpServer with all tools registered.
// Must be called per-request in stateless HTTP mode (the SDK forbids connecting
// the same McpServer instance to more than one transport).
const createConfiguredMcpServer = () => {
const server = new McpServer(
{
name: 'Dynatrace Managed MCP Server',
version: getPackageJsonVersion(),
},
{
capabilities: {
tools: {},
},
instructions: `
This MCP server connects to Dynatrace Managed (self-hosted) environments for Observabilitiy. This can include metrics, logs and traces,
and detection of problems and security vulnerabilities relating to these.
Some users may configure two MCPs at the same time: this MCP to connect to their Dynatrace Managed instances, and a second MCP to connect to their SaaS environment.
Be careful of which MCP to use. If it is unclear, ask the user which they want to use. Ask the user to confirm the difference between their two environments.
**Key Context:**
- This MCP server accesses self-hosted Dynatrace Managed clusters (not the SaaS version of Dynatrace)
- This MCP server can be used to interact with multiple Dynatrace Managed environments
- Minimum supported cluster version: ${authClientManager.MINIMUM_VERSION}
- Two different ways that Dynatrace Managed may be being used:
1. Dynatrace Managed may be the primary Observability system, containing all live data.
2. Or alternatively the customer may have migrated to Dynatrace SaaS, leavng historical observability data in Dynatrace Managed from before the migration, in which case this MCP Server would only be used to access historical data.
**Core Capabilities:**
- **Problem Analysis**: Investigate problems with root cause identification
- **Security Assessment**: Comprehensive vulnerability scanning and risk assessment
- **Log Investigation**: Search logs using either simple text search or advanced query syntax with time-based filtering
- **Event Tracking**: Monitor system events, deployments, and configuration changes
- **Entity Exploration**: Discover and analyze monitored entities, including relationship mapping
- **Metrics Analysis**: Query observability metrics via the Dynatrace Metrics V2 API
- **SLO Management**: Service Level Objective monitoring, error budget analysis, and SLO evaluation tracking
**Best Practices:**
- Must start by calling the tool get_environments_info. It will return a list of the available environments, including
details of connection errors and configuration errors.
- **CRITICAL: must report issues with environment configurations and connections to the user before any other requests**.
- On every subsequent request, an "environment_alias" must be passed.
- If the user wants information of all available environments, "environment_alias" MUST be "ALL_ENVIRONMENTS"
- Use specific time ranges (1-2 hours) rather than large historical queries for better performance
- Leverage entity selectors to filter data at the source - they are fundamental to getting good results
- Use problem IDs (UUID format) from list_problems, not display IDs (P-XXXXX)
- **When users specify counts** (e.g., "first 25 errors", "50 metrics", "100 errors"), always use the "limit" parameter in tools rather than guessing with searchText
- **Avoid searchText guessing** - only use searchText when user explicitly mentions keywords to search for
- **discover_entities ALWAYS requires entitySelector** - never call this tool without providing an entitySelector with exactly ONE entity type like type("SERVICE") unless using an EntityId. Multiple entity types are NOT supported.
- **Next Steps are important** All requests will come back with a footer called 'Next Steps'. Take into consideration what it says.
**Time Range Parameters:**
- **Relative Times**: now-1h, now-24h, now-7d, now-30d (h=hours, d=days, m=minutes, s=seconds)
- **ISO Format**: 2024-01-01T10:00:00Z or 2024-01-01T10:00:00
- **Unix Timestamps**: 1640995200000 (milliseconds since epoch)
- **Common Patterns**:
- "last hour" → from: "now-1h", to: "now"
- "past 24 hours" → from: "now-24h", to: "now"
- "last week" → from: "now-7d", to: "now"
- "yesterday" → from: "now-24h", to: "now-0h"
- "last 6 hours" → from: "now-6h", to: "now"
- "past 30 minutes" → from: "now-30m", to: "now"
**Entity Selector Guidelines**
- **CRITICAL CONSTRAINT**: You can select only ONE entity type per query. Multiple entity types are NOT supported in a single query.
- **Key Rule**: Dynatrace Managed requires type() specification unless using entityId() with full IDs
- **For multiple specific entity ids**: Use entityId("ID1","ID2","ID3") with comma-separated IDs (all entities must be same type); must never combine multiple entityId selectors with OR
- **For name-based filtering**: Use type("SERVICE"),entityName("exact-name") or type("SERVICE"),entityName.contains("partial")
- **To set several criteria, separate them with a comma. For example, type("HOST"),healthState("HEALTHY"). Only results matching all criteria are included in the response.
- Example Valid Entity Selectors:
- type("SERVICE"),entityName.contains("bookstore")
- entityId("SERVICE-123","SERVICE-456","SERVICE-789")
- entityId("SERVICE-1234567890ABCDEF")
- type("AWS_LAMBDA_FUNCTION"),tag("AWS_REGION:us-west-2")
- type("SERVICE"),tag("environment:production"),entityName.contains("api")
- type("HOST"),mzName("Production")
- Example INVALID Entity Selectors (NEVER USE THESE):**
- type(SERVICE),type(PROCESS_GROUP) - Invalid because supports only one type per query
- entityName("my-service") - Invalid because type must be defined if an explicit entityId is not specified
- entityId("ID1") or entityId("ID2") - OR operator not supported for entityId criteria, instead use a single criteria with entityId("ID1","ID2")
**Log Search Guidelines:**
- Simple query: specify the text to search for, such as "error". This search is case-insensitive.
- More complex queries: you can specify that the text should be part of the content of the log message, with: content="error"
This critiera can be combined with more other search criteria, such as: content="error" AND dt.entity.host="HOST-94A1B472D04D89D9"
**Common Workflows:**
For problem or incident investigation:
1. list_problems
2. get_problem_details
For security Assessment:
1. list_security_problems
2. get_security_problem_details
For SLO Analysis:
1. list_slos
2. get_slo_details
For entity-based Analysis:
1. list_entity_types
2. discover_entities (ALWAYS with entitySelector)
3. get_entity_details (using use exact entityId)
4. list_problems or list_events, using the entityId in the entitySelector.
Always be cautious to avoid overloading the self-hosted Dynatrace Managed clusters.
Never run queries that could return very large amounts of data, or that could be very expensive to compute.
`,
},
);
// Ready to start the server
logger.info(`Starting Dynatrace Managed MCP Server v${getPackageJsonVersion()}...`);
// Tool wrapper for consistent error handling and telemetry
const tool = (
name: string,
description: string,
paramsSchema: ZodRawShape,
annotations: ToolAnnotations,
cb: (args: any) => Promise<string>,
) => {
const wrappedCb = async (args: any): Promise<CallToolResult> => {
// Capture starttime for telemetry and rate limiting
const startTime = Date.now();
/**
* Rate Limit: configurable via DT_MCP_RATE_LIMIT_MAX_CALLS and DT_MCP_RATE_LIMIT_WINDOW_MS.
* Defaults: max 20 requests per 20 seconds.
*/
const windowStart = startTime - RATE_LIMIT_WINDOW_MS;
// First, remove all tool calls older than the window
toolCallTimestamps = toolCallTimestamps.filter((ts) => ts > windowStart);
// Second, check whether we have reached the limit
if (toolCallTimestamps.length >= RATE_LIMIT_MAX_CALLS) {
logger.debug(`Rate-limiting tool execution: ${name}; args: ${JSON.stringify(args)}`);
return {
content: [
{
type: 'text',
text: `Rate limit exceeded: Maximum ${RATE_LIMIT_MAX_CALLS} tool calls per ${RATE_LIMIT_WINDOW_MS / 1000} seconds. Please try again later.`,
},
],
isError: true,
};
}
// Last but not least, record this call
toolCallTimestamps.push(startTime);
/** Rate Limit End */
let toolCallSuccessful = false;
try {
logger.debug(`Executing tool: ${name}; args: ${JSON.stringify(args)}`);
const response = await cb(args);
toolCallSuccessful = true;
logger.debug(
`Executed tool: ${name}; args: ${JSON.stringify(args)}; response length ${response.length} chars; ${response}`,
);
return {
content: [{ type: 'text', text: response }],
};
} catch (error: any) {
telemetry
.trackError(error, `tool_${name}`)
.catch((e) => logger.warn(`Failed to track error: ${e.message}`, { error: e }));
logger.error(`Failed to run tool ${name}: ${error.message}`, { error: error });
logger.error(error);
return {
content: [{ type: 'text', text: `Error: ${error.message}` }],
isError: true,
};
} finally {
const duration = Date.now() - startTime;
telemetry
.trackMcpToolUsage(name, toolCallSuccessful, duration)
.catch((e) => logger.warn(`Failed to track tool usage: ${e.message}`, { error: e }));
}
};
(server.registerTool as any)(
name,
{
description,
inputSchema: paramsSchema,
annotations,
},
wrappedCb,
);
};
const envAliasValidate = (alias: string) => {
if (alias == 'ALL_ENVIRONMENTS') {
return true;
}
const env_list = alias.split(';');
for (const env_alias of env_list) {
if (!authClientManager.validAliases.includes(env_alias)) {
return false;
}
}
return true;
};
tool(
'dynatrace_managed_check_config_errors',
'Returns information about environment configurations and any potential error found during initialization',
{},
{
readOnlyHint: true,
},
async ({}) => {
let resp = `Dynatrace Managed Environments Information - Listing configuration errors found during initialization:\n\n`;
if (initErrors.length > 0) {
resp += `Issues where found in environment configurations during start up: \n`;
for (const errorMessage of initErrors) {
resp += `- ${errorMessage}\n`;
}
resp += `\nPlease review all environment information and try again. \n`;
}
return resp;
},
);
tool(
'dynatrace_managed_get_environments_info',
'Get information about all connected Dynatrace Managed clusters and verify the connections and authentication services.',
{},
{
readOnlyHint: true,
},
async ({}) => {
let resp = `Dynatrace Managed Cluster Information - Listing info for ${authClientManager.rawClients.length} environments:\n\n`;
for (let authClient of authClientManager.rawClients) {
resp += `- Environment Alias: ${authClient.alias}\n`;
resp += `- API URL: ${authClient.apiBaseUrl}\n`;
resp += `- Dashboard URL: ${authClient.dashboardBaseUrl}\n`;
resp += `- Valid Environment: ${authClient.isValid ? 'Yes' : 'No'}\n`;
let clusterVersion;
let isValidVersion;
if (authClient.isValid) {
clusterVersion = await authClient.getClusterVersion();
isValidVersion = authClient.validateMinimumVersion(clusterVersion);
resp += `- Version: ${clusterVersion.version}\n`;
resp += `- Minimum Version Check: ${isValidVersion ? 'PASSED' : 'WARNING - Version may not be fully compatible and may not support all features'}\n`;
resp += `- Available API Scopes: ${MANAGED_API_SCOPES.join(', ')}\n\n\n`;
} else {
resp += `- Error message: ${authClient.validationError}\n`;
}
}
if (initErrors.length > 0) {
resp += `Issues were found in environment configurations during start up: \n`;
for (const errorMessage of initErrors) {
resp += `- ${errorMessage}\n`;
}
resp += `\nPlease review all environments connection information. \n`;
}
resp += `\n\n\nAll Dynatrace Managed Cluster Environments listed. Environment showing connection errors and environments with "Valid environment" set to "No" are invalid environments.\n\n`;
return resp;
},
);
tool(
'dynatrace_managed_list_available_metrics',
`List available metrics in the Managed cluster, optionally filtered by entity. Results include aggregation
types, dimension definitions, and technical metadata for advanced metric analysis.`,
{
entitySelector: z
.string()
.optional()
.describe(
`Entity selector to filter metrics. Must use at most one entity type per query.
Examples include:
* type(SERVICE)
* entityId("id1","id2")
* type(HOST)
Can combine with things like: entityName.contains("name"), tag("key:value"), mzName("zone").`,
),
searchText: z
.string()
.optional()
.describe(
`Text to search for in metric names and descriptions.
**RECOMMENDED SEARCHES**: "response.time" (latency), "cpu.usage" (CPU), "memory" (memory),
"error.rate" (errors), "throughput" (performance), "availability" (uptime)`,
),
limit: z
.number()
.optional()
.describe(
`Maximum number of metrics to return. Use this when user specifies a count
(e.g., "first 16 metrics" → limit: 16, "500 metrics" → limit: 500).
If not specified, returns up to API limit: ${MetricsApiClient.API_PAGE_SIZE}`,
),
environment_alias: z
.string()
.describe(
'Specify which environment to be queried, by supplying the environment alias as returned ' +
'by get_environments_info. Can use `ALL_ENVIRONMENTS` to retrieve data from all environments in ' +
'one request to MCP.',
)
.refine((alias) => envAliasValidate(alias), {
message: 'Environment alias(es) not valid. Options are: ' + authClientManager.validAliases.join(', '),
}),
},
{
readOnlyHint: true,
},
async ({ entitySelector, searchText, limit, environment_alias }) => {
const responses = await metricsClient.listAvailableMetrics(
{
entitySelector: entitySelector,
text: searchText,
pageSize: limit,
},
environment_alias,
);
return metricsClient.formatMetricList(responses);
},
);
tool(
'dynatrace_managed_query_metrics_data',
`Query metric data for a specific time range and metric selector.
Must limit the amount of data being retrieved:
must use a specific entitySelector, such as using specific entityIds;
must use a narrow timerange (with from and to);
must use a resolution in line with the timerange, for example if getting data covering several days then the resolution should be hours rather than minutes.`,
{
metricSelector: z.string().describe(
`Metric selector (e.g., "builtin:service.response.time" for latency,
"builtin:tech.generic.cpu.usage" for container CPU, "builtin:host.mem.usage" for memory).
Consider first using the tool list_available_metrics to identity the right metric.`,
),
from: z.string().describe('Start time (ISO format or relative like "now-1h")'),
to: z.string().describe('End time (ISO format or relative like "now")'),
resolution: z
.string()
.optional()
.describe(
`Data resolution. Use a bigger resolution when the timerange is larger.
For example, use "5m" for detailed analysis of data over hour(s), use "1h" for trends of data over a day, use 6h or 1d for data over many days.`,
),
entitySelector: z
.string()
.optional()
.describe(
`Entity selector to filter metrics data. CRITICAL: Only ONE entity type per query.
Use discover_entities() first to get exact names/IDs, then use entityId("exact-id") or
type(SERVICE),entityName.equals("exact-name"). Examples: entityId("SERVICE-123"),
type(SERVICE),entityName("payment-service"), type(AWS_LAMBDA_FUNCTION),tag("AWS_REGION:us-west-2")`,
),
environment_alias: z
.string()
.describe(
'Specify which environment to be queried, by supplying the environment alias as returned ' +
'by get_environments_info. Can use `ALL_ENVIRONMENTS` to retrieve data from all environments in ' +
'one request to MCP.',
)
.refine((alias) => envAliasValidate(alias), {
message: 'Environment alias(es) not valid. Options are: ' + authClientManager.validAliases.join(', '),
}),
},
{
readOnlyHint: true,
},
async ({ metricSelector, from, to, resolution, entitySelector, environment_alias }) => {
const responses = await metricsClient.queryMetrics(
{
metricSelector: metricSelector,
from: from,
to: to,
resolution: resolution,
entitySelector: entitySelector,
},
environment_alias,
);
return metricsClient.formatMetricData(responses);
},
);
tool(
'dynatrace_managed_get_metric_details',
'Get detailed information about a specific metric.',
{
metricId: z.string().describe('The metric ID to get details for'),
environment_alias: z
.string()
.describe(
'Specify which environment to be queried, by supplying the environment alias as returned ' +
'by get_environments_info. Can use `ALL_ENVIRONMENTS` to retrieve data from all environments in ' +
'one request to MCP.',
)
.refine((alias) => envAliasValidate(alias), {
message: 'Environment alias(es) not valid. Options are: ' + authClientManager.validAliases.join(', '),
}),
},
{
readOnlyHint: true,
},
async ({ metricId, environment_alias }) => {
const responses = await metricsClient.getMetricDetails(metricId, environment_alias);
return metricsClient.formatMetricDetails(responses);
},
);
tool(
'dynatrace_managed_query_logs',
`Search logs using simple text queries. Results include event types, expanded metadata fields
(up to 8 fields), and enhanced error detection. Managed clusters support basic text search but
not structured syntax like "content:" or "loglevel:".`,
{
query: z.string().describe(
`Simple text to search for in log content (e.g., "error", "exception", "timeout").
Do NOT use structured syntax like "content:error" - just use "error".`,
),
from: z.string().describe('Start time (ISO format or relative like "now-1h")'),
to: z.string().describe('End time (ISO format or relative like "now")'),
limit: z.number().optional().describe('Maximum number of logs to return (default: 100)'),
sort: z.string().optional().describe('Sort order for logs. Use "-timestamp" for most recent first.'),
environment_alias: z
.string()
.describe(
'Specify which environment to be queried, by supplying the environment alias as returned ' +
'by get_environments_info. Can use `ALL_ENVIRONMENTS` to retrieve data from all environments in ' +
'one request to MCP.',
)
.refine((alias) => envAliasValidate(alias), {
message: 'Environment alias(es) not valid. Options are: ' + authClientManager.validAliases.join(', '),
}),
},
{
readOnlyHint: true,
},
async ({ query, from, to, limit, sort, environment_alias }) => {
const responses = await logsClient.queryLogs(
{
query: query,
from: from,
to: to,
limit: limit,
sort: sort,
},
environment_alias,
);
return logsClient.formatList(responses);
},
);
tool(
'dynatrace_managed_list_events',
`List events from the Managed cluster within a specified timeframe. Results include event properties,
management zones, severity/impact levels, and detailed metadata for comprehensive analysis.`,
{
from: z.string().describe('Start time (ISO format or relative like "now-1h")'),
to: z.string().describe('End time (ISO format or relative like "now")'),
eventType: z
.string()
.optional()
.describe(
`Filter by event type (e.g., "CONTAINER_RESTART" for certain container issues,
"CUSTOM_DEPLOYMENT" for deployments, "RESOURCE_CONTENTION_EVENT" for resource issues)`,
),
entitySelector: z
.string()
.optional()
.describe(
`Entity selector to filter events. CRITICAL: Only ONE entity type per query.
Use discover_entities() first to get exact names/IDs, then use entityId("exact-id") or
type(SERVICE),entityName.equals("exact-name"). Examples: entityId("SERVICE-123"),
type(SERVICE),entityName("payment-service"), type(AWS_LAMBDA_FUNCTION),tag("AWS_REGION:us-west-2")`,
),
limit: z
.number()
.optional()
.describe(
`Maximum number of events to return. Use this when user specifies a count (e.g., "first 20 events" → limit: 20). If not specified, returns up to API limit: ${EventsApiClient.API_PAGE_SIZE}`,
),
environment_alias: z
.string()
.describe(
'Specify which environment to be queried, by supplying the environment alias as returned ' +
'by get_environments_info. Can use `ALL_ENVIRONMENTS` to retrieve data from all environments in ' +
'one request to MCP.',
)
.refine((alias) => envAliasValidate(alias), {
message: 'Environment alias(es) not valid. Options are: ' + authClientManager.validAliases.join(', '),
}),
},
{
readOnlyHint: true,
},
async ({ from, to, eventType, entitySelector, limit, environment_alias }) => {
const responses = await eventsClient.queryEvents(
{
from: from,
to: to,
eventType: eventType,
entitySelector: entitySelector,
pageSize: limit,
},
environment_alias,
);
return eventsClient.formatList(responses);
},
);
tool(
'dynatrace_managed_get_event_details',
'Get detailed information about a specific event.',
{
eventId: z.string().describe('The event ID to get details for'),
environment_alias: z
.string()
.describe(
'Specify which environment to be queried, by supplying the environment alias as returned ' +
'by get_environments_info. Can use `ALL_ENVIRONMENTS` to retrieve data from all environments in ' +
'one request to MCP.',
)
.refine((alias) => envAliasValidate(alias), {
message: 'Environment alias(es) not valid. Options are: ' + authClientManager.validAliases.join(', '),
}),
},
{
readOnlyHint: true,
},
async ({ eventId, environment_alias }) => {
const response = await eventsClient.getEventDetails(eventId, environment_alias);
return eventsClient.formatDetails(response);
},
);
tool(
'dynatrace_managed_list_entity_types',
'List all available entity types in the Managed cluster to understand what types of entities can be monitored.',
{
environment_alias: z
.string()
.describe(
'Specify which environment to be queried, by supplying the environment alias as returned ' +
'by get_environments_info. Can use `ALL_ENVIRONMENTS` to retrieve data from all environments in ' +
'one request to MCP.',
)
.refine((alias) => envAliasValidate(alias), {
message: 'Environment alias(es) not valid. Options are: ' + authClientManager.validAliases.join(', '),
}),
},
{
readOnlyHint: true,
},
async ({ environment_alias }) => {
const responses = await entitiesClient.listEntityTypes(environment_alias);
return entitiesClient.formatEntityTypeList(responses);
},
);
tool(
'dynatrace_managed_get_entity_type_details',
'Get details of an entity type.',
{
type: z.string().describe('Name of the entity type, such as SERVICE, APPLICATION, HOST, etc'),
environment_alias: z
.string()
.describe(
'Specify which environment to be queried, by supplying the environment alias as returned ' +
'by get_environments_info. Can use `ALL_ENVIRONMENTS` to retrieve data from all environments in ' +
'one request to MCP.',
)
.refine((alias) => envAliasValidate(alias), {
message: 'Environment alias(es) not valid. Options are: ' + authClientManager.validAliases.join(', '),
}),
},
{
readOnlyHint: true,
},
async ({ type, environment_alias }) => {
const response = await entitiesClient.getEntityTypeDetails(type, environment_alias);
return entitiesClient.formatEntityTypeDetails(response);
},
);
tool(
'dynatrace_managed_discover_entities',
`Discover entities in the Managed cluster using EntitySelector syntax. REQUIRED: Must specify
entitySelector with exactly ONE entity type only. Results include entity properties, tags,
management zones, and relationship counts for comprehensive topology analysis.`,
{
entitySelector: z.string().describe(
`Entity selector to filter the entities. CRITICAL: Must include exactly ONE entity type
like type("SERVICE") - multiple types NOT supported. Examples: type("SERVICE"),
entityId("ID1"), entityName.contains("name"), entityName.equals("exact"), tag("key:value"), mzName("zone"),
healthState("HEALTHY").`,
),
mzSelector: z
.string()
.optional()
.describe(
`Optional management zone selector to further scope the query. Use mzId(123,456) for zone IDs
or mzName("Bookstore-FS","Stocks") for zone names. Can combine: mzId(123),mzName("Production").
Works alongside entitySelector.`,
),
from: z
.string()
.optional()
.describe('Start time for entity observation timeframe (ISO format or relative like "now-3d")'),
to: z
.string()
.optional()
.describe('End time for entity observation timeframe (ISO format or relative like "now")'),
limit: z
.number()
.optional()
.describe(
`Maximum number of entities to return. Use this when user specifies a count (e.g., "first 10 entities" → limit: 10). If not specified, returns up to API limit: ${EntitiesApiClient.API_PAGE_SIZE}`,
),
sort: z
.string()
.optional()
.describe('Sort order for entities. Use "name" for ascending, "-name" for descending by display name.'),
environment_alias: z
.string()
.describe(
'Specify which environment to be queried, by supplying the environment alias as returned ' +
'by get_environments_info. Can use `ALL_ENVIRONMENTS` to retrieve data from all environments in ' +
'one request to MCP.',
)
.refine((alias) => envAliasValidate(alias), {
message: 'Environment alias(es) not valid. Options are: ' + authClientManager.validAliases.join(', '),
}),
},
{
readOnlyHint: true,
},
async ({ entitySelector, mzSelector, from, to, limit, sort, environment_alias }) => {
const responses = await entitiesClient.queryEntities(
{
entitySelector: entitySelector,
pageSize: limit,
mzSelector: mzSelector,
from: from,
to: to,
sort: sort,
},
environment_alias,
);
return entitiesClient.formatEntityList(responses);
},
);
tool(
'dynatrace_managed_get_entity_details',
'Get detailed information about a specific entity.',
{
entityId: z.string().describe('The entity ID to get details for'),
environment_alias: z
.string()
.describe(
'Specify which environment to be queried, by supplying the environment alias as returned ' +
'by get_environments_info. Can use `ALL_ENVIRONMENTS` to retrieve data from all environments in ' +
'one request to MCP.',
)
.refine((alias) => envAliasValidate(alias), {
message: 'Environment alias(es) not valid. Options are: ' + authClientManager.validAliases.join(', '),
}),
},
{
readOnlyHint: true,
},
async ({ entityId, environment_alias }) => {
const response = await entitiesClient.getEntityDetails(entityId, environment_alias);
return entitiesClient.formatEntityDetails(response);
},
);
tool(
'dynatrace_managed_get_entity_relationships',
'Get relationships that a specific entity has "to" and "from" other entities.',
{
entityId: z.string().describe('The entity ID to get relationships for'),
environment_alias: z
.string()
.describe(
'Specify which environment to be queried, by supplying the environment alias as returned ' +
'by get_environments_info. Can use `ALL_ENVIRONMENTS` to retrieve data from all environments in ' +
'one request to MCP.',
)
.refine((alias) => envAliasValidate(alias), {
message: 'Environment alias(es) not valid. Options are: ' + authClientManager.validAliases.join(', '),
}),
},
{
readOnlyHint: true,
},
async ({ entityId, environment_alias }) => {
const responses = await entitiesClient.getEntityRelationships(entityId, environment_alias);
return entitiesClient.formatEntityRelationships(responses);
},
);
tool(
'dynatrace_managed_list_problems',
'List problems from the Managed cluster with optional filtering.',
{
from: z.string().optional().describe('Start time (default: "now-24h")'),
to: z.string().optional().describe('End time (default: "now")'),
status: z
.string()
.optional()
.describe('Problem status - use "OPEN" for active issues, "CLOSED" for resolved problems'),
impactLevel: z
.string()
.optional()
.describe(
'Impact level - use "SERVICE" for application issues, "INFRASTRUCTURE" for host/container problems, "APPLICATION" for user-facing issues',
),
entitySelector: z
.string()
.optional()
.describe(
'Entity selector to filter problems. CRITICAL: Only ONE entity type per query. Use discover_entities() first to get exact names/IDs, then use entityId("exact-id") or type(SERVICE),entityName.equals("exact-name"). Examples: entityId("SERVICE-123"), type(SERVICE),entityName("payment-service"), type(AWS_LAMBDA_FUNCTION),tag("AWS_REGION:us-west-2")',
),
limit: z
.number()
.optional()
.describe(
`Maximum number of problems to return. Use this when user specifies a count (e.g., "first 10 problems" → limit: 10). If not specified, returns up to API limit: ${ProblemsApiClient.API_PAGE_SIZE}`,
),
sort: z
.string()
.optional()
.describe(
'Sort order. Use "+status" (open first), "-status" (closed first), "+startTime" (old first), "-startTime" (new first), or "+relevance"/"-relevance" (with text search).',
),
environment_alias: z
.string()
.describe(
'Specify which environment to be queried, by supplying the environment alias as returned ' +
'by get_environments_info. Can use `ALL_ENVIRONMENTS` to retrieve data from all environments in ' +
'one request to MCP.',
)
.refine((alias) => envAliasValidate(alias), {
message: 'Environment alias(es) not valid. Options are: ' + authClientManager.validAliases.join(', '),
}),
},
{
readOnlyHint: true,
},
async ({ from, to, status, impactLevel, entitySelector, limit, sort, environment_alias }) => {
const responses = await problemsClient.listProblems(
{
from: from || 'now-24h',
to: to || 'now',
status: status,
impactLevel: impactLevel,
entitySelector: entitySelector,
pageSize: limit,
sort: sort,
},
environment_alias,
);
return problemsClient.formatList(responses);
},
);
tool(
'dynatrace_managed_get_problem_details',
'Get detailed information about a specific problem including evidence details for root cause analysis, affected entities, entity tags, and management zones. Use the problemId (UUID format) from list_problems output, NOT the displayId.',
{
problemId: z
.string()
.describe('The internal problem ID (UUID format) from list_problems - NOT the displayId (P-XXXXX)'),
environment_alias: z
.string()
.describe(
'Specify which environment to be queried, by supplying the environment alias as returned ' +
'by get_environments_info. Can use `ALL_ENVIRONMENTS` to retrieve data from all environments in ' +
'one request to MCP.',
)
.refine((alias) => envAliasValidate(alias), {
message: 'Environment alias(es) not valid. Options are: ' + authClientManager.validAliases.join(', '),
}),
},
{
readOnlyHint: true,
},
async ({ problemId, environment_alias }) => {
const response = await problemsClient.getProblemDetails(problemId, environment_alias);
return problemsClient.formatDetails(response);
},
);
tool(
'dynatrace_managed_list_security_problems',
'List security problems and vulnerabilities from the Managed cluster. Results include package names, technology details, vulnerable components, and comprehensive risk assessment data.',
{
riskLevel: z.string().optional().describe('Filter by risk level (LOW, MEDIUM, HIGH, CRITICAL)'),
status: z.string().optional().describe('Filter by status (OPEN, RESOLVED, MUTED)'),
entitySelector: z
.string()
.optional()
.describe(
'Entity selector to filter vulnerabilities. CRITICAL: Only ONE entity type per query. Use discover_entities() first to get exact names/IDs, then use entityId("exact-id") or type(SERVICE),entityName.equals("exact-name"). Examples: entityId("SERVICE-123"), type(SERVICE),entityName("payment-service"), type(AWS_LAMBDA_FUNCTION),tag("AWS_REGION:us-west-2")',
),
from: z.string().optional().describe('Start time (default: "now-30d")'),
to: z.string().optional().describe('End time (default: "now")'),
limit: z
.number()
.optional()
.describe(
`Maximum number of security problems to return. Use this when user specifies a count (e.g., "first 25 vulnerabilities" → limit: 25). If not specified, returns up to API limit: ${SecurityApiClient.API_PAGE_SIZE}`,
),
sort: z
.string()
.optional()
.describe(
'Sort order. Examples: "+status" (open first), "-riskAssessment.riskScore" (highest risk first), "+firstSeenTimestamp" (newest first), "-lastUpdatedTimestamp" (recently updated first).',
),
environment_alias: z
.string()
.describe(
'Specify which environment to be queried, by supplying the environment alias as returned ' +
'by get_environments_info. Can use `ALL_ENVIRONMENTS` to retrieve data from all environments in ' +
'one request to MCP.',
)
.refine((alias) => envAliasValidate(alias), {
message: 'Environment alias(es) not valid. Options are: ' + authClientManager.validAliases.join(', '),
}),
},
{
readOnlyHint: true,
},
async ({ riskLevel, status, entitySelector, from, to, limit, sort, environment_alias }) => {
const responses = await securityClient.listSecurityProblems(
{
riskLevel: riskLevel,
status: status,
entitySelector: entitySelector,
from: from,
to: to,
pageSize: limit,
sort: sort,
},
environment_alias,
);
return securityClient.formatList(responses);
},
);
tool(
'dynatrace_managed_get_security_problem_details',
'Get detailed information about a specific security problem including CVE details, affected entities, vulnerable components, code locations, and comprehensive technical analysis.',
{
securityProblemId: z
.string()
.describe('The security problem ID (UUID format) from list_security_problems - NOT the displayId (S-XXXXX)'),
environment_alias: z
.string()
.describe(
'Specify which environment to be queried, by supplying the environment alias as returned ' +
'by get_environments_info. Can use `ALL_ENVIRONMENTS` to retrieve data from all environments in ' +
'one request to MCP.',
)
.refine((alias) => envAliasValidate(alias), {
message: 'Environment alias(es) not valid. Options are: ' + authClientManager.validAliases.join(', '),
}),
},
{
readOnlyHint: true,
},
async ({ securityProblemId, environment_alias }) => {
const response = await securityClient.getSecurityProblemDetails(securityProblemId, environment_alias);
return securityClient.formatDetails(response);
},
);
tool(
'dynatrace_managed_list_slos',
'List Service Level Objectives (SLOs) from the Managed cluster. Results include timeframe details, management zones, error budget burn rates, and comprehensive SLO configuration data. IMPORTANT: When evaluate=true, the query must be limited to 25 or fewer results using the limit parameter.',
{
sloSelector: z
.string()
.optional()
.describe(
'SLO selector to filter results. Syntax: id("id-1","id-2") for SLO IDs, name("Service Availability") for exact name match (case-sensitive), healthState("HEALTHY"|"UNHEALTHY") [requires evaluate=true], text("value") for case-insensitive text search, problemDisplayName("P-12345") for problem display names, managementZone("MZ-A") or managementZoneID("123") for management zones. Combine with commas. Escape special characters ~ and " with ~.',
),
timeFrame: z
.string()
.optional()
.describe(
'Time frame for SLO evaluation: "CURRENT" for SLO\'s own timeframe, "GTF" for custom timeframe specified by from/to parameters',
),
from: z
.string()
.optional()
.describe('Start time (ISO format or relative like "now-2w"). Used when timeFrame="GTF"'),
to: z.string().optional().describe('End time (ISO format or relative like "now"). Used when timeFrame="GTF"'),
evaluate: z
.boolean()
.optional()
.describe('Set to true to enable SLO evaluation. Required when using healthState in sloSelector.'),
sort: z
.string()
.optional()
.describe(
'Sorting of SLO entries: "name" for ascending order, "-name" for descending order. Default: "name"',
),
enabledSlos: z
.string()
.optional()
.describe(
'Filter by SLO status: "true" for enabled SLOs only, "false" for disabled only, "all" for both. Default: "true"',
),
showGlobalSlos: z
.boolean()
.optional()
.describe('Include global SLOs in results regardless of other filters. Default: true'),
demo: z.boolean().optional().describe('Get demo SLOs instead of real ones. Default: false'),
limit: z
.number()
.optional()
.describe(
`Maximum number of SLOs to return. Use this when user specifies a count (e.g., "first 15 SLOs" → limit: 15). If not specified, returns up to API limit: ${SloApiClient.API_PAGE_SIZE}`,
),
environment_alias: z
.string()
.describe(
'Specify which environment to be queried, by supplying the environment alias as returned ' +
'by get_environments_info. Can use `ALL_ENVIRONMENTS` to retrieve data from all environments in ' +
'one request to MCP.',
)
.refine((alias) => envAliasValidate(alias), {
message: 'Environment alias(es) not valid. Options are: ' + authClientManager.validAliases.join(', '),
}),
},
{
readOnlyHint: true,
},
async ({
sloSelector,
timeFrame,
from,
to,
evaluate,
sort,
enabledSlos,
showGlobalSlos,
demo,
limit,
environment_alias,
}) => {
const responses = await sloClient.listSlos(
{
sloSelector: sloSelector,
timeFrame: timeFrame,
from: from,
to: to,
evaluate: evaluate,
sort: sort,
enabledSlos: enabledSlos,
showGlobalSlos: showGlobalSlos,
demo: demo,
pageSize: limit,
},
environment_alias,
);
return sloClient.formatList(responses);
},
);
tool(
'dynatrace_managed_get_slo_details',
'Get detailed information about a specific SLO.',
{
sloId: z.string().describe('The SLO ID to get details for'),
from: z
.string()
.optional()
.describe('Start time (ISO format or relative like "now-1w"). Used when timeFrame="GTF"'),
end: z.string().optional().describe('End time (ISO format or relative like "now"). Used when timeFrame="GTF"'),
timeFrame: z
.string()
.optional()
.describe(
'Time frame for SLO evaluation: "CURRENT" for SLO\'s own timeframe, "GTF" for custom timeframe specified by from and to parameters',
),
environment_alias: z
.string()
.describe(
'Specify which environment to be queried, by supplying the environment alias as returned ' +
'by get_environments_info. Can use `ALL_ENVIRONMENTS` to retrieve data from all environments in ' +
'one request to MCP.',
)
.refine((alias) => envAliasValidate(alias), {
message: 'Environment alias(es) not valid. Options are: ' + authClientManager.validAliases.join(', '),
}),
},
{
readOnlyHint: true,
},
async ({ sloId, from, to, timeFrame, environment_alias }) => {
const response = await sloClient.getSloDetails(
{
id: sloId,
from: from,
to: to,
timeFrame: timeFrame,
},
environment_alias,
);
return sloClient.formatDetails(response);
},
);
// Wrap the tools/list request handler to strip properties that cause some MCP clients
// (e.g. Copilot CLI) to receive a 400 Bad Request from their AI model API:
// - $schema: added by zod-to-json-schema; rejected by GitHub Copilot model API
// - additionalProperties: false: triggers OpenAI strict-mode validation which requires
// ALL properties to be listed in `required`; our tools have optional params so this
// combination is rejected. Removing it allows optional params without strict-mode errors.
// Both properties are optional per JSON Schema spec; removing them does not affect
// schema validity or tool behaviour for any other client.
type ToolsListHandler = (req: unknown) => Promise<{ tools: Array<Record<string, unknown>> }>;
const innerServer = (server as unknown as { server: { _requestHandlers: Map<string, ToolsListHandler> } }).server;
const originalToolsListHandler = innerServer._requestHandlers.get('tools/list');
if (originalToolsListHandler) {
innerServer._requestHandlers.set('tools/list', async (req: unknown) => {
const result = await originalToolsListHandler(req);
for (const tool of result.tools) {
const schema = tool['inputSchema'] as Record<string, unknown> | undefined;
if (schema) {
delete schema['$schema'];
delete schema['additionalProperties'];
}
}
return result;
});
}
return server;
}; // end createConfiguredMcpServer
// Parse command line arguments using commander
const program = new Command();
program
.name('dynatrace-managed-mcp')
.description('Dynatrace Managed Model Context Protocol (MCP) Server')
.version(getPackageJsonVersion())
.option('--http', 'enable HTTP server mode instead of stdio')
.option('--server', 'enable HTTP server mode (alias for --http)')
.option('-p, --port <number>', 'port for HTTP server', '3000')
.option('-H, --host <host>', 'host for HTTP server', '127.0.0.1')
.parse();
const options = program.opts();
const httpMode = options.http || options.server;
const httpPort = parseInt(options.port, 10);
const host = options.host || '127.0.0.1';
// HTTP server mode (Stateless)
if (httpMode) {
const httpServer = createServer(async (req: IncomingMessage, res: ServerResponse) => {
// Parse request body for POST requests
let body: unknown;
// Create a new Stateless HTTP Transport
// enableJsonResponse: true returns application/json instead of keeping SSE streams open,
// which is required for MCP clients that don't support persistent SSE connections (e.g. Copilot CLI).
const httpTransport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined, // No Session ID needed
enableJsonResponse: true,
});
// Create a fresh McpServer per request (stateless HTTP requirement)
const server = createConfiguredMcpServer();
res.on('close', () => {
// close transport and server, but not the httpServer itself
httpTransport.close();
server.close();
});
// Connecting MCP-server to HTTP transport
await server.connect(httpTransport);
// Handle POST Requests for this endpoint
if (req.method === 'POST') {
const chunks: Buffer[] = [];
for await (const chunk of req) {
chunks.push(chunk);
}
const rawBody = Buffer.concat(chunks).toString();
try {
body = JSON.parse(rawBody);
} catch (error) {
res.writeHead(400, { 'Content-Type': 'application/json' });
// Respond with a JSON-RPC Parse error
res.end(JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } }));
return;
}
}
await httpTransport.handleRequest(req, res, body);
});
// Start HTTP Server on the specified host and port
httpServer.listen(httpPort, host, () => {
logger.info(`Dynatrace Managed MCP Server running on HTTP at http://${host}:${httpPort}`);
console.error(`Dynatrace Managed MCP Server running on HTTP at http://${host}:${httpPort}`);
});
// Handle graceful shutdown for http server mode
process.on(
'SIGINT',
shutdownHandler(
async () => await telemetry.shutdown(),
() =>
new Promise<void>((resolve) => {
httpServer.closeAllConnections?.(); // Force close all connections (Node.js 18.2+)
httpServer.close(() => resolve());
}),
),
);
process.on(
'SIGTERM',
shutdownHandler(
async () => await telemetry.shutdown(),
() =>
new Promise<void>((resolve) => {
httpServer.closeAllConnections?.(); // Force close all connections (Node.js 18.2+)
httpServer.close(() => resolve());
}),
),
);
} else {
// Default stdio mode
const server = createConfiguredMcpServer();
const transport = new StdioServerTransport();
// Warn if LOG_OUTPUT is set to stdout/console (won't work with stdio)
const logOutput = (process.env.LOG_OUTPUT || '').toLowerCase();
if (
logOutput === 'console' ||
logOutput === 'stdout' ||
logOutput === 'file+console' ||
logOutput === 'file+stdout'
) {
console.error(
`WARNING: LOG_OUTPUT=${process.env.LOG_OUTPUT} won't show logs in stdio transport. ` +
`Stdout is reserved for MCP protocol. Use LOG_OUTPUT=stderr-all or LOG_OUTPUT=file instead.`,
);
}
logger.info('Connecting server to transport...');
await server.connect(transport);
logger.info('Dynatrace Managed MCP Server running on stdio');
console.error('Dynatrace Managed MCP Server running on stdio');
// Handle graceful shutdown for stdio mode
process.on(
'SIGINT',
shutdownHandler(async () => await telemetry.shutdown()),
);
process.on(
'SIGTERM',
shutdownHandler(async () => await telemetry.shutdown()),
);
}
};
main().catch(async (error) => {
logger.error(`Fatal error in main(): ${error.message}`, { error: error });
try {
// report error in main
const telemetry = createTelemetry();
await telemetry.trackError(error, 'main_error');
await telemetry.shutdown();
} catch (e: any) {
logger.error(`Failed to track fatal error: ${e.message}`, { error: e });
}
await flushLogger();
process.exit(1);
});