@dynatrace-oss/dynatrace-mcp-server 1.3.1 → 1.5.0-beta.1

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 CHANGED
@@ -24,6 +24,8 @@
24
24
  </a>
25
25
  </h4>
26
26
 
27
+ > šŸš€ **Try out our brand new [Remote Dynatrace MCP Server](https://www.dynatrace.com/hub/detail/dynatrace-mcp-server/)!** Now available - no local setup required, connect instantly to your Dynatrace environment from any MCP-compatible client.
28
+
27
29
  The local _Dynatrace MCP server_ allows AI Assistants to interact with the [Dynatrace](https://www.dynatrace.com/) observability platform,
28
30
  bringing real-time observability data directly into your development workflow.
29
31
 
@@ -34,6 +34,7 @@ const createResultAndLog = (queryResult, logPrefix, budgetLimitGB) => {
34
34
  const result = {
35
35
  records: queryResult.records,
36
36
  metadata: queryResult.metadata,
37
+ types: queryResult.types ?? [],
37
38
  scannedBytes,
38
39
  scannedRecords: queryResult.metadata?.grail?.scannedRecords,
39
40
  executionTimeMilliseconds: queryResult.metadata?.grail?.executionTimeMilliseconds,
package/dist/index.js CHANGED
@@ -3,10 +3,14 @@
3
3
  Object.defineProperty(exports, "__esModule", { value: true });
4
4
  const client_platform_management_service_1 = require("@dynatrace-sdk/client-platform-management-service");
5
5
  const shared_errors_1 = require("@dynatrace-sdk/shared-errors");
6
+ // Dynamically imported below (ESM-only package)
7
+ // import { registerAppTool, registerAppResource, RESOURCE_MIME_TYPE } from '@modelcontextprotocol/ext-apps/server';
6
8
  const stdio_js_1 = require("@modelcontextprotocol/sdk/server/stdio.js");
7
9
  const streamableHttp_js_1 = require("@modelcontextprotocol/sdk/server/streamableHttp.js");
8
10
  const mcp_js_1 = require("@modelcontextprotocol/sdk/server/mcp.js");
9
11
  const node_http_1 = require("node:http");
12
+ const node_fs_1 = require("node:fs");
13
+ const node_path_1 = require("node:path");
10
14
  const commander_1 = require("commander");
11
15
  const zod_1 = require("zod");
12
16
  const version_1 = require("./utils/version");
@@ -31,6 +35,7 @@ const dynatrace_connection_utils_1 = require("./utils/dynatrace-connection-utils
31
35
  const proxy_config_1 = require("./utils/proxy-config");
32
36
  const list_exceptions_1 = require("./capabilities/list-exceptions");
33
37
  const notebooks_1 = require("./capabilities/notebooks");
38
+ const environment_url_parser_1 = require("./utils/environment-url-parser");
34
39
  const DT_MCP_AUTH_CODE_FLOW_OAUTH_CLIENT_ID = 'dt0s12.local-dt-mcp-server';
35
40
  // Rate limiting state: store timestamps of tool calls
36
41
  let toolCallTimestamps = [];
@@ -75,6 +80,9 @@ const allRequiredScopes = scopesBase.concat([
75
80
  'document:documents:write', // Create and update documents
76
81
  ]);
77
82
  const main = async () => {
83
+ // Dynamic import: @modelcontextprotocol/ext-apps is ESM-only and can't be require()'d.
84
+ const dynamicImport = new Function('specifier', 'return import(specifier)');
85
+ const { registerAppTool, registerAppResource, RESOURCE_MIME_TYPE } = await dynamicImport('@modelcontextprotocol/ext-apps/server');
78
86
  console.error(`Initializing Dynatrace MCP Server v${(0, version_1.getPackageJsonVersion)()}...`);
79
87
  // Configure proxy from environment variables early in the startup process
80
88
  (0, proxy_config_1.configureProxyFromEnvironment)();
@@ -94,8 +102,10 @@ const main = async () => {
94
102
  console.error('No OAuth credentials or platform token provided - switching to OAuth authorization code flow.');
95
103
  oauthClientId = DT_MCP_AUTH_CODE_FLOW_OAUTH_CLIENT_ID; // Default OAuth client ID for auth code flow
96
104
  }
105
+ // Parse environment information for telemetry
106
+ const environmentInfo = (0, environment_url_parser_1.parseEnvironmentUrl)(dtEnvironment);
97
107
  // Initialize usage tracking
98
- const telemetry = (0, telemetry_openkit_1.createTelemetry)();
108
+ const telemetry = (0, telemetry_openkit_1.createTelemetry)(environmentInfo);
99
109
  await telemetry.trackMcpServerStart();
100
110
  // Create a shutdown handler that takes shutdown operations as parameters
101
111
  const shutdownHandler = (...shutdownOps) => {
@@ -178,9 +188,9 @@ const main = async () => {
178
188
  }
179
189
  // Ready to start the server
180
190
  console.error(`Starting Dynatrace MCP Server v${(0, version_1.getPackageJsonVersion)()}...`);
181
- // quick abstraction/wrapper to make it easier for tools to reply text instead of JSON
182
- const tool = (name, title, description, paramsSchema, annotations, cb) => {
183
- const wrappedCb = async (args) => {
191
+ // Wraps a string-returning tool callback with rate limiting, telemetry, and error handling
192
+ const wrapToolCallback = (name, cb) => {
193
+ return async (args, _extra) => {
184
194
  // Capture starttime for telemetry and rate limiting
185
195
  const startTime = Date.now();
186
196
  /**
@@ -207,9 +217,16 @@ const main = async () => {
207
217
  // call the tool
208
218
  const response = await cb(args);
209
219
  toolCallSuccessful = true;
210
- return {
211
- content: [{ type: 'text', text: response }],
220
+ // Support callbacks that return either a plain string or { text, _meta }
221
+ const text = typeof response === 'string' ? response : response.text;
222
+ const _meta = typeof response === 'string' ? undefined : response._meta;
223
+ const result = {
224
+ content: [{ type: 'text', text }],
212
225
  };
226
+ if (_meta) {
227
+ result._meta = _meta;
228
+ }
229
+ return result;
213
230
  }
214
231
  catch (error) {
215
232
  // Track error
@@ -236,12 +253,15 @@ const main = async () => {
236
253
  .catch((e) => console.warn('Failed to track tool usage:', e));
237
254
  }
238
255
  };
256
+ };
257
+ // quick abstraction/wrapper to make it easier for tools to reply text instead of JSON
258
+ const tool = (name, title, description, paramsSchema, annotations, cb) => {
239
259
  server.registerTool(name, {
240
260
  title: title,
241
261
  description: description,
242
262
  inputSchema: zod_1.z.object(paramsSchema),
243
263
  annotations: annotations,
244
- }, (args) => wrappedCb(args));
264
+ }, (args) => wrapToolCallback(name, cb)(args));
245
265
  };
246
266
  /**
247
267
  * Helper function to request human approval for potentially sensitive operations
@@ -420,13 +440,13 @@ const main = async () => {
420
440
  const validSmartscapeEntities = smartscapeResult.records.filter((entity) => !!(entity && entity.id && entity.type && entity.name));
421
441
  let resp = `Found ${validSmartscapeEntities.length} monitored entities via Smartscape! Displaying the first ${Math.min(maxEntitiesToDisplay, validSmartscapeEntities.length)} valid entities:\n`;
422
442
  validSmartscapeEntities.slice(0, maxEntitiesToDisplay).forEach((entity) => {
423
- resp += `- Entity '${entity.name}' of entity-type '${entity.type}' has entity id '${entity.id}' and tags ${entity['tags'] ? JSON.stringify(entity['tags']) : 'none'} - DQL Filter: '| filter dt.smartscape.${String(entity.type).toLowerCase()} == "${entity.id}"'\n`;
443
+ resp += `- Entity '${entity.name}' of entity-type '${entity.type}' has entity id '${entity.id}' and tags ${entity['tags'] ? JSON.stringify(entity['tags']) : 'none'} - DQL Filter: '| filter dt.smartscape.${String(entity.type).toLowerCase()} == toSmartscapeId("${entity.id}")'\n`;
424
444
  });
425
445
  resp +=
426
446
  '\n\n**Next Steps:**\n' +
427
447
  '1. Fetch more details about the entity, using the `execute_dql` tool with the following DQL Statement: "smartscapeNodes \"<entity-type>\" | filter id == <entity-id>"\n' +
428
448
  '2. Perform a sanity check that found entities are actually the ones you are looking for, by comparing name and by type (hosts vs. containers vs. apps vs. functions) and technology (Java, TypeScript, .NET) with what is available in the local source code repo.\n' +
429
- '3. Find and investigate available metrics for relevant entities, by using the `execute_dql` tool with the following DQL statement: "fetch metric.series | filter dt.smartscape.<entity-type> == <entity-id> | limit 20"\n' +
449
+ '3. Find and investigate available metrics for relevant entities, by using the `execute_dql` tool with the following DQL statement: "fetch metric.series | filter dt.smartscape.<entity-type> == toSmartscapeId(<entity-id>) | limit 20"\n' +
430
450
  '4. Find out whether any problems exist for this entity using the `list_problems` or `list_vulnerabilities` tool, and the provided DQL-Filter\n' +
431
451
  '5. Explore dependency & relationships with: "smartscapeEdges \"*\" | filter source_id == <entity-id> or target_id == <entity-id>" to list inbound/outbound edges (depends_on, dependency_of, owned_by, part_of) for graph context\n';
432
452
  return resp;
@@ -495,26 +515,39 @@ const main = async () => {
495
515
  }
496
516
  return resp;
497
517
  });
498
- tool('execute_dql', 'Execute DQL', 'Get data like Logs, Metrics, Spans, Events, or Entity Data from Dynatrace GRAIL by executing a Dynatrace Query Language (DQL) statement. ' +
499
- 'Use the "generate_dql_from_natural_language" tool upfront to generate or refine a DQL statement based on your request. ' +
500
- 'To learn about possible fields available for filtering, use the query "fetch dt.semantic_dictionary.models | filter data_object == \"logs\""', {
501
- dqlStatement: zod_1.z
502
- .string()
503
- .describe('DQL Statement (Ex: "fetch [logs, spans, events, metric.series, ...], from: now()-4h, to: now() [| filter <some-filter>] [| summarize count(), by:{some-fields}]", or for metrics: "timeseries { avg(<metric-name>), value.A = avg(<metric-name>, scalar: true) }", or for entities via smartscape: "smartscapeNodes \"[*, HOST, PROCESS, ...]\" [| filter id == "<ENTITY-ID>"]"). ' +
504
- 'When querying data for a specific entity, call the `find_entity_by_name` tool first to get an appropriate filter like `dt.entity.service == "SERVICE-1234"` or `dt.entity.host == "HOST-1234"` to be used in the DQL statement. '),
505
- recordLimit: zod_1.z.number().optional().default(100).describe('Maximum number of records to return (default: 100)'),
506
- recordSizeLimitMB: zod_1.z
507
- .number()
508
- .optional()
509
- .default(1)
510
- .describe('Maximum size of the returned records in MB (default: 1MB)'),
511
- }, {
512
- // not readonly (DQL statements may modify things), not idempotent (may change over time)
513
- readOnlyHint: false,
514
- idempotentHint: false,
515
- // while we are not strictly talking to the open world here, the response from execute DQL could interpreted as a web-search, which often is referred to open-world
516
- openWorldHint: true,
517
- }, async ({ dqlStatement, recordLimit = 100, recordSizeLimitMB = 1 }) => {
518
+ // MCP App: Define the resource URI for the execute_dql interactive UI
519
+ // The ui:// scheme tells hosts this is an MCP App resource.
520
+ const executeDqlResourceUri = 'ui://execute-dql/execute-dql.html';
521
+ // Register the execute_dql tool with MCP App UI support.
522
+ registerAppTool(server, 'execute_dql', {
523
+ title: 'Execute DQL',
524
+ description: 'Get data like Logs, Metrics, Spans, Events, or Entity Data from Dynatrace GRAIL by executing a Dynatrace Query Language (DQL) statement. ' +
525
+ 'Use the "generate_dql_from_natural_language" tool upfront to generate or refine a DQL statement based on your request. ' +
526
+ 'To learn about possible fields available for filtering, use the query "fetch dt.semantic_dictionary.models | filter data_object == \\"logs\\""',
527
+ inputSchema: {
528
+ dqlStatement: zod_1.z
529
+ .string()
530
+ .describe('DQL Statement (Ex: "fetch [logs, spans, events, metric.series, ...], from: now()-4h, to: now() [| filter <some-filter>] [| summarize count(), by:{some-fields}]", or for metrics: "timeseries { avg(<metric-name>), value.A = avg(<metric-name>, scalar: true) }", or for entities via smartscape: "smartscapeNodes \\"[*, HOST, PROCESS, ...]\\" [| filter id == \\"<ENTITY-ID>\\"]"). ' +
531
+ 'When querying data for a specific entity, call the `find_entity_by_name` tool first to get an appropriate filter like `dt.entity.service == "SERVICE-1234"` or `dt.entity.host == "HOST-1234"` to be used in the DQL statement. '),
532
+ recordLimit: zod_1.z.number().optional().default(100).describe('Maximum number of records to return (default: 100)'),
533
+ recordSizeLimitMB: zod_1.z
534
+ .number()
535
+ .optional()
536
+ .default(1)
537
+ .describe('Maximum size of the returned records in MB (default: 1MB)'),
538
+ },
539
+ annotations: {
540
+ // not readonly (DQL statements may modify things), not idempotent (may change over time)
541
+ readOnlyHint: false,
542
+ idempotentHint: false,
543
+ // while we are not strictly talking to the open world here, the response from execute DQL could interpreted as a web-search, which often is referred to open-world
544
+ openWorldHint: true,
545
+ },
546
+ _meta: {
547
+ // MCP App
548
+ ui: { resourceUri: executeDqlResourceUri },
549
+ },
550
+ }, wrapToolCallback('execute_dql', async ({ dqlStatement, recordLimit = 100, recordSizeLimitMB = 1 }) => {
518
551
  // Create a HTTP Client that has all storage:*:read scopes
519
552
  const dtClient = await createAuthenticatedHttpClient(scopesBase.concat('storage:buckets:read', // Read all system data stored on Grail
520
553
  'storage:logs:read', // Read logs for reliability guardian validations
@@ -578,7 +611,25 @@ const main = async () => {
578
611
  }
579
612
  result += `\nšŸ“‹ **Query Results**: (${response.records?.length || 0} records):\n\n`;
580
613
  result += `\`\`\`json\n${JSON.stringify(response.records, null, 2)}\n\`\`\``;
614
+ // Include field type definitions for chart rendering in the UI
615
+ if (response.types && response.types.length > 0) {
616
+ result += `\n\nšŸ“Š **Field Types**:\n\n`;
617
+ result += `\`\`\`json:types\n${JSON.stringify(response.types)}\n\`\`\``;
618
+ }
619
+ // Include analysisTimeframe metadata for chart rendering in the UI.
620
+ // This is needed when timeseries results lack explicit timeframe/interval
621
+ // columns (e.g. timeseries queries with fieldsRemove or custom projections).
622
+ if (response.metadata?.grail?.analysisTimeframe) {
623
+ result += `\n\n\`\`\`json:analysisTimeframe\n${JSON.stringify(response.metadata.grail.analysisTimeframe)}\n\`\`\``;
624
+ }
581
625
  return result;
626
+ }));
627
+ // MCP App: Register the HTML resource for the execute_dql interactive UI (MCP App)
628
+ registerAppResource(server, 'DQL Results Viewer', executeDqlResourceUri, {}, async () => {
629
+ const html = (0, node_fs_1.readFileSync)((0, node_path_1.join)(__dirname, 'ui', 'execute-dql', 'index.html'), 'utf-8');
630
+ return {
631
+ contents: [{ uri: executeDqlResourceUri, mimeType: RESOURCE_MIME_TYPE, text: html }],
632
+ };
582
633
  });
583
634
  tool('generate_dql_from_natural_language', '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.', {
584
635
  text: zod_1.z
@@ -1145,8 +1196,8 @@ You can now execute new Grail queries (DQL, etc.) again. If this happens more of
1145
1196
  main().catch(async (error) => {
1146
1197
  console.error('Fatal error in main():', error);
1147
1198
  try {
1148
- // report error in main
1149
- const telemetry = (0, telemetry_openkit_1.createTelemetry)();
1199
+ // report error in main - use unknown environment info since we might not have parsed it yet
1200
+ const telemetry = (0, telemetry_openkit_1.createTelemetry)({ environmentId: 'unknown', stage: 'unknown' });
1150
1201
  await telemetry.trackError(error, 'main_error');
1151
1202
  await telemetry.shutdown();
1152
1203
  }