mcp-instana 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,319 @@
1
+ """
2
+ Infrastructure Topology MCP Tools Module
3
+
4
+ This module provides infrastructure topology-specific MCP tools for Instana monitoring.
5
+ """
6
+
7
+ import sys
8
+ import traceback
9
+ from typing import Dict, Any, Optional, List, Union
10
+ from datetime import datetime
11
+
12
+ # Import the necessary classes from the SDK
13
+ try:
14
+ from instana_client.api.infrastructure_topology_api import InfrastructureTopologyApi
15
+ from instana_client.api_client import ApiClient
16
+ from instana_client.configuration import Configuration
17
+ from instana_client.models.topology import Topology
18
+ except ImportError as e:
19
+ traceback.print_exc(file=sys.stderr)
20
+ raise
21
+
22
+ from .instana_client_base import BaseInstanaClient, register_as_tool
23
+
24
+ # Helper function for debug printing
25
+ def debug_print(*args, **kwargs):
26
+ """Print debug information to stderr instead of stdout"""
27
+ print(*args, file=sys.stderr, **kwargs)
28
+
29
+ class InfrastructureTopologyMCPTools(BaseInstanaClient):
30
+ """Tools for infrastructure topology in Instana MCP."""
31
+
32
+ def __init__(self, read_token: str, base_url: str):
33
+ """Initialize the Infrastructure Topology MCP tools client."""
34
+ super().__init__(read_token=read_token, base_url=base_url)
35
+
36
+ try:
37
+
38
+ # Configure the API client with the correct base URL and authentication
39
+ configuration = Configuration()
40
+ configuration.host = base_url
41
+ configuration.api_key['ApiKeyAuth'] = read_token
42
+ configuration.api_key_prefix['ApiKeyAuth'] = 'apiToken'
43
+
44
+ # Create an API client with this configuration
45
+ api_client = ApiClient(configuration=configuration)
46
+
47
+ # Initialize the Instana SDK's InfrastructureTopologyApi with our configured client
48
+ self.topo_api = InfrastructureTopologyApi(api_client=api_client)
49
+ except Exception as e:
50
+ debug_print(f"Error initializing InfrastructureTopologyApi: {e}")
51
+ traceback.print_exc(file=sys.stderr)
52
+ raise
53
+
54
+ @register_as_tool
55
+ async def get_related_hosts(self,
56
+ snapshot_id: str,
57
+ to_time: Optional[int] = None,
58
+ window_size: Optional[int] = None,
59
+ ctx=None) -> Dict[str, Any]:
60
+ """
61
+ Get hosts related to a specific snapshot.
62
+
63
+ This tool retrieves a list of host IDs that are related to the specified snapshot. Use this when you need to
64
+ understand the relationships between infrastructure components, particularly which hosts are connected to
65
+ a specific entity.
66
+
67
+ For example, use this tool when:
68
+ - You need to find all hosts connected to a specific container, process, or service
69
+ - You want to understand the infrastructure dependencies of an application component
70
+ - You're investigating an issue and need to see which hosts might be affected
71
+
72
+ Args:
73
+ snapshot_id: The ID of the snapshot to find related hosts for (required)
74
+ to_time: End timestamp in milliseconds (optional)
75
+ window_size: Window size in milliseconds (optional)
76
+ ctx: The MCP context (optional)
77
+
78
+ Returns:
79
+ Dictionary containing related hosts information or error information
80
+ """
81
+ try:
82
+ debug_print(f"get_related_hosts called with snapshot_id={snapshot_id}")
83
+
84
+ if not snapshot_id:
85
+ return {"error": "snapshot_id parameter is required"}
86
+
87
+ # Call the get_related_hosts method from the SDK
88
+ result = self.topo_api.get_related_hosts(
89
+ snapshot_id=snapshot_id,
90
+ to=to_time,
91
+ window_size=window_size
92
+ )
93
+
94
+ # Convert the result to a dictionary
95
+ if isinstance(result, list):
96
+ result_dict = {
97
+ "relatedHosts": result,
98
+ "count": len(result),
99
+ "snapshotId": snapshot_id
100
+ }
101
+ else:
102
+ # For any other type, convert to string representation
103
+ result_dict = {"data": str(result), "snapshotId": snapshot_id}
104
+
105
+ debug_print(f"Result from get_related_hosts: {result_dict}")
106
+ return result_dict
107
+
108
+ except Exception as e:
109
+ debug_print(f"Error in get_related_hosts: {e}")
110
+ import traceback
111
+ traceback.print_exc(file=sys.stderr)
112
+ return {"error": f"Failed to get related hosts: {str(e)}"}
113
+
114
+ @register_as_tool
115
+ async def get_topology(self,
116
+ include_data: Optional[bool] = False,
117
+ ctx=None) -> Dict[str, Any]:
118
+ """
119
+ Get the infrastructure topology information.
120
+
121
+ This tool retrieves the complete infrastructure topology from Instana, showing how all monitored entities
122
+ are connected. Use this when you need a comprehensive view of your infrastructure's relationships and dependencies.
123
+
124
+ The topology includes nodes (representing entities like hosts, processes, containers) and edges (representing
125
+ connections between entities). This is useful for understanding the overall structure of your environment.
126
+
127
+ For example, use this tool when:
128
+ - You need a complete map of your infrastructure
129
+ - You want to understand how components are connected
130
+ - You're analyzing dependencies between systems
131
+ - You need to visualize your infrastructure's architecture
132
+
133
+ Args:
134
+ include_data: Whether to include detailed snapshot data in nodes (optional, default: False)
135
+ ctx: The MCP context (optional)
136
+
137
+ Returns:
138
+ Dictionary containing infrastructure topology information with detailed summary or error information
139
+ """
140
+ try:
141
+ debug_print(f"get_topology called - using include_data=False to avoid validation issues")
142
+
143
+ # Try to call the SDK method and handle validation errors
144
+ try:
145
+ result = self.topo_api.get_topology(include_data=False)
146
+ debug_print(f"SDK call successful, processing result")
147
+ except Exception as sdk_error:
148
+ debug_print(f"SDK validation error: {sdk_error}")
149
+
150
+ # If it's a validation error, try to extract useful information from the error
151
+ if "validation error" in str(sdk_error).lower():
152
+ return {
153
+ "error": "SDK validation error occurred",
154
+ "details": str(sdk_error),
155
+ "suggestion": "The API response format may not match the expected SDK model structure. This often happens with complex Kubernetes or cloud infrastructure data.",
156
+ "workaround": "Consider using other topology tools like get_related_hosts with specific snapshot IDs, or check if the include_data parameter affects the response format."
157
+ }
158
+ else:
159
+ # Re-raise if it's not a validation error
160
+ raise sdk_error
161
+
162
+ # Convert the result to a dictionary
163
+ result_dict = None
164
+
165
+ # Try different ways to convert the result
166
+ if hasattr(result, 'to_dict'):
167
+ try:
168
+ result_dict = result.to_dict()
169
+ debug_print("Successfully converted result using to_dict()")
170
+ except Exception as e:
171
+ debug_print(f"to_dict() failed: {e}")
172
+
173
+ if result_dict is None and isinstance(result, dict):
174
+ result_dict = result
175
+ debug_print("Result is already a dictionary")
176
+
177
+ if result_dict is None:
178
+ # Try to extract data from the result object manually
179
+ try:
180
+ if hasattr(result, '__dict__'):
181
+ result_dict = result.__dict__
182
+ debug_print("Extracted data using __dict__")
183
+ else:
184
+ result_dict = {"data": str(result)}
185
+ debug_print("Converted result to string representation")
186
+ except Exception as e:
187
+ debug_print(f"Manual extraction failed: {e}")
188
+ result_dict = {"data": str(result)}
189
+
190
+ # Process the result if we have valid data
191
+ if isinstance(result_dict, dict) and ('nodes' in result_dict or 'data' in result_dict):
192
+ nodes = result_dict.get('nodes', [])
193
+ edges = result_dict.get('edges', [])
194
+
195
+ debug_print(f"Processing {len(nodes)} nodes and {len(edges)} edges")
196
+
197
+ # If we have no nodes but have data, try to extract from data field
198
+ if not nodes and 'data' in result_dict:
199
+ debug_print("No nodes found, checking data field")
200
+ return {
201
+ "summary": {
202
+ "status": "Data retrieved but in unexpected format",
203
+ "dataType": type(result_dict.get('data')).__name__,
204
+ "dataPreview": str(result_dict.get('data'))[:200] + "..." if len(str(result_dict.get('data'))) > 200 else str(result_dict.get('data'))
205
+ },
206
+ "rawDataAvailable": True,
207
+ "note": "Topology data was retrieved but not in the expected nodes/edges format"
208
+ }
209
+
210
+ # Take only first 30 nodes for analysis to avoid token limits
211
+ sample_nodes = nodes[:30] if len(nodes) > 30 else nodes
212
+ sample_edges = edges[:30] if len(edges) > 30 else edges
213
+
214
+ # Count nodes by plugin type from sample
215
+ plugin_counts = {}
216
+ host_info = {}
217
+ kubernetes_resources = {}
218
+ sample_nodes_details = []
219
+
220
+ for node in sample_nodes:
221
+ if not isinstance(node, dict):
222
+ continue
223
+
224
+ plugin = node.get('plugin', 'unknown')
225
+ plugin_counts[plugin] = plugin_counts.get(plugin, 0) + 1
226
+
227
+ # Keep minimal node info for sample
228
+ node_label = str(node.get('label', 'unknown'))
229
+ if len(node_label) > 40:
230
+ node_label = node_label[:37] + "..."
231
+
232
+ node_id = str(node.get('id', ''))
233
+ if len(node_id) > 15:
234
+ node_id = node_id[:12] + "..."
235
+
236
+ sample_nodes_details.append({
237
+ 'plugin': plugin,
238
+ 'label': node_label,
239
+ 'id': node_id
240
+ })
241
+
242
+ # Extract host information
243
+ if plugin == 'host':
244
+ label = str(node.get('label', 'unknown'))
245
+ host_info[label] = str(node.get('id', ''))
246
+
247
+ # Group Kubernetes resources
248
+ if plugin.startswith('kubernetes'):
249
+ k8s_type = plugin.replace('kubernetes', '').lower()
250
+ if k8s_type not in kubernetes_resources:
251
+ kubernetes_resources[k8s_type] = 0
252
+ kubernetes_resources[k8s_type] += 1
253
+
254
+ # Estimate total counts based on sample
255
+ sample_size = len(sample_nodes)
256
+ total_size = len(nodes)
257
+ scaling_factor = total_size / sample_size if sample_size > 0 else 1
258
+
259
+ estimated_plugin_counts = {}
260
+ for plugin, count in plugin_counts.items():
261
+ estimated_plugin_counts[plugin] = int(count * scaling_factor)
262
+
263
+ # Create comprehensive summary
264
+ summary = {
265
+ 'totalNodes': len(nodes),
266
+ 'totalEdges': len(edges),
267
+ 'sampleAnalysis': {
268
+ 'sampleSize': sample_size,
269
+ 'scalingFactor': round(scaling_factor, 2),
270
+ 'note': f'Analysis based on first {sample_size} nodes out of {total_size} total'
271
+ },
272
+ 'topPluginTypes': dict(list(sorted(estimated_plugin_counts.items(), key=lambda x: x[1], reverse=True))[:10]),
273
+ 'infrastructureOverview': {
274
+ 'estimatedHosts': int(len(host_info) * scaling_factor),
275
+ 'sampleHosts': list(host_info.keys())[:3], # Show first 3 hosts
276
+ 'kubernetesTypes': kubernetes_resources,
277
+ 'estimatedContainers': int((plugin_counts.get('crio', 0) + plugin_counts.get('containerd', 0) + plugin_counts.get('docker', 0)) * scaling_factor),
278
+ 'estimatedProcesses': int(plugin_counts.get('process', 0) * scaling_factor)
279
+ }
280
+ }
281
+
282
+ # Add edge analysis from sample if available
283
+ if sample_edges:
284
+ edge_types = {}
285
+ for edge in sample_edges:
286
+ if isinstance(edge, dict):
287
+ edge_type = edge.get('type', 'unknown')
288
+ edge_types[edge_type] = edge_types.get(edge_type, 0) + 1
289
+
290
+ if edge_types:
291
+ summary['connectionAnalysis'] = {
292
+ 'sampleEdgeTypes': edge_types,
293
+ 'sampleEdgesAnalyzed': len(sample_edges)
294
+ }
295
+
296
+ # Return compact summary
297
+ return {
298
+ 'summary': summary,
299
+ 'sampleNodes': sample_nodes_details[:8], # Just 8 example nodes
300
+ 'status': 'success',
301
+ 'note': 'Topology data processed successfully with sampling to manage size'
302
+ }
303
+ else:
304
+ return {
305
+ "error": "Unexpected data format",
306
+ "dataType": type(result_dict).__name__,
307
+ "availableKeys": list(result_dict.keys()) if isinstance(result_dict, dict) else "Not a dictionary",
308
+ "suggestion": "The topology data may be in a different format than expected"
309
+ }
310
+
311
+ except Exception as e:
312
+ debug_print(f"Error in get_topology: {e}")
313
+ import traceback
314
+ traceback.print_exc(file=sys.stderr)
315
+ return {
316
+ "error": f"Failed to get topology: {str(e)}",
317
+ "errorType": type(e).__name__,
318
+ "suggestion": "This may be due to API response format changes or network issues"
319
+ }
@@ -0,0 +1,93 @@
1
+ """
2
+ Base Instana API Client Module
3
+
4
+ This module provides the base client for interacting with the Instana API.
5
+ """
6
+
7
+ import sys
8
+ import requests
9
+ from typing import Dict, Any, AsyncIterator
10
+ from contextlib import asynccontextmanager
11
+
12
+ # Registry to store all tools
13
+ MCP_TOOLS = {}
14
+
15
+ def register_as_tool(func):
16
+ """Decorator to register a method as an MCP tool."""
17
+ MCP_TOOLS[func.__name__] = func
18
+ return func
19
+
20
+ class BaseInstanaClient:
21
+ """Base client for Instana API with common functionality."""
22
+
23
+ def __init__(self, read_token: str, base_url: str):
24
+ self.read_token = read_token
25
+ self.base_url = base_url
26
+
27
+ def get_headers(self):
28
+ """Get standard headers for Instana API requests."""
29
+ return {
30
+ "Authorization": f"apiToken {self.read_token}",
31
+ "Content-Type": "application/json",
32
+ "Accept": "application/json"
33
+ }
34
+
35
+ async def make_request(self, endpoint: str, params: Dict[str, Any] = None, method: str = "GET", json: Dict[str, Any] = None) -> Dict[str, Any]:
36
+ """Make a request to the Instana API."""
37
+ url = f"{self.base_url}/{endpoint.lstrip('/')}"
38
+ headers = self.get_headers()
39
+
40
+ try:
41
+ if method.upper() == "GET":
42
+ response = requests.get(url, headers=headers, params=params, verify=False)
43
+ elif method.upper() == "POST":
44
+ # Use the json parameter if provided, otherwise use params
45
+ data_to_send = json if json is not None else params
46
+ response = requests.post(url, headers=headers, json=data_to_send, verify=False)
47
+ elif method.upper() == "PUT":
48
+ data_to_send = json if json is not None else params
49
+ response = requests.put(url, headers=headers, json=data_to_send, verify=False)
50
+ elif method.upper() == "DELETE":
51
+ response = requests.delete(url, headers=headers, params=params, verify=False)
52
+ else:
53
+ return {"error": f"Unsupported HTTP method: {method}"}
54
+
55
+ response.raise_for_status()
56
+ return response.json()
57
+ except requests.exceptions.HTTPError as err:
58
+ print(f"HTTP Error: {err}", file=sys.stderr)
59
+ return {"error": f"HTTP Error: {err}"}
60
+ except requests.exceptions.RequestException as err:
61
+ print(f"Error: {err}", file=sys.stderr)
62
+ return {"error": f"Error: {err}"}
63
+ except Exception as e:
64
+ print(f"Unexpected error: {str(e)}", file=sys.stderr)
65
+ return {"error": f"Unexpected error: {str(e)}"}
66
+
67
+ @asynccontextmanager
68
+ async def instana_api_token(read_token: str, base_url: str) -> AsyncIterator[Dict[str, BaseInstanaClient]]:
69
+ """
70
+ Context manager for creating and managing Instana API clients.
71
+ Returns a dictionary of client instances for different API groups.
72
+ """
73
+ # Import here to avoid circular imports
74
+ from .infrastructure_mcp_tools import InfrastructureMCPTools
75
+ from .application_mcp_tools import ApplicationClient
76
+
77
+ # Create the standard clients
78
+ infra_client = InfrastructureMCPTools(read_token=read_token, base_url=base_url)
79
+ app_client = ApplicationClient(read_token=read_token, base_url=base_url)
80
+
81
+ # Initialize clients dictionary
82
+ clients = {
83
+ "infrastructure": infra_client,
84
+ "application": app_client,
85
+ }
86
+
87
+ try:
88
+ yield clients
89
+ except Exception as e:
90
+ print(f"Error in Instana API client: {e}", file=sys.stderr)
91
+ finally:
92
+ # Clean up resources if needed
93
+ pass