awslabs.eks-mcp-server 0.1.1__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.
- awslabs/__init__.py +13 -0
- awslabs/eks_mcp_server/__init__.py +14 -0
- awslabs/eks_mcp_server/aws_helper.py +71 -0
- awslabs/eks_mcp_server/cloudwatch_handler.py +670 -0
- awslabs/eks_mcp_server/consts.py +33 -0
- awslabs/eks_mcp_server/eks_kb_handler.py +86 -0
- awslabs/eks_mcp_server/eks_stack_handler.py +661 -0
- awslabs/eks_mcp_server/iam_handler.py +359 -0
- awslabs/eks_mcp_server/k8s_apis.py +506 -0
- awslabs/eks_mcp_server/k8s_client_cache.py +164 -0
- awslabs/eks_mcp_server/k8s_handler.py +1151 -0
- awslabs/eks_mcp_server/logging_helper.py +52 -0
- awslabs/eks_mcp_server/models.py +271 -0
- awslabs/eks_mcp_server/server.py +151 -0
- awslabs/eks_mcp_server/templates/eks-templates/eks-with-vpc.yaml +454 -0
- awslabs/eks_mcp_server/templates/k8s-templates/deployment.yaml +49 -0
- awslabs/eks_mcp_server/templates/k8s-templates/service.yaml +18 -0
- awslabs_eks_mcp_server-0.1.1.dist-info/METADATA +596 -0
- awslabs_eks_mcp_server-0.1.1.dist-info/RECORD +23 -0
- awslabs_eks_mcp_server-0.1.1.dist-info/WHEEL +4 -0
- awslabs_eks_mcp_server-0.1.1.dist-info/entry_points.txt +2 -0
- awslabs_eks_mcp_server-0.1.1.dist-info/licenses/LICENSE +175 -0
- awslabs_eks_mcp_server-0.1.1.dist-info/licenses/NOTICE +2 -0
|
@@ -0,0 +1,1151 @@
|
|
|
1
|
+
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance
|
|
4
|
+
# with the License. A copy of the License is located at
|
|
5
|
+
#
|
|
6
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
7
|
+
#
|
|
8
|
+
# or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES
|
|
9
|
+
# OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions
|
|
10
|
+
# and limitations under the License.
|
|
11
|
+
|
|
12
|
+
"""Kubernetes handler for the EKS MCP Server."""
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
import yaml
|
|
16
|
+
from awslabs.eks_mcp_server.k8s_apis import K8sApis
|
|
17
|
+
from awslabs.eks_mcp_server.k8s_client_cache import K8sClientCache
|
|
18
|
+
from awslabs.eks_mcp_server.logging_helper import LogLevel, log_with_request_id
|
|
19
|
+
from awslabs.eks_mcp_server.models import (
|
|
20
|
+
ApiVersionsResponse,
|
|
21
|
+
ApplyYamlResponse,
|
|
22
|
+
EventItem,
|
|
23
|
+
EventsResponse,
|
|
24
|
+
GenerateAppManifestResponse,
|
|
25
|
+
KubernetesResourceListResponse,
|
|
26
|
+
KubernetesResourceResponse,
|
|
27
|
+
Operation,
|
|
28
|
+
PodLogsResponse,
|
|
29
|
+
ResourceSummary,
|
|
30
|
+
)
|
|
31
|
+
from mcp.server.fastmcp import Context
|
|
32
|
+
from mcp.types import TextContent
|
|
33
|
+
from pydantic import Field
|
|
34
|
+
from typing import Any, Dict, Optional
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class K8sHandler:
|
|
38
|
+
"""Handler for Kubernetes operations in the EKS MCP Server.
|
|
39
|
+
|
|
40
|
+
This class provides tools for interacting with Kubernetes clusters, including
|
|
41
|
+
applying YAML manifests.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
mcp,
|
|
47
|
+
allow_write: bool = False,
|
|
48
|
+
allow_sensitive_data_access: bool = False,
|
|
49
|
+
):
|
|
50
|
+
"""Initialize the Kubernetes handler.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
mcp: The MCP server instance
|
|
54
|
+
allow_write: Whether to enable write access (default: False)
|
|
55
|
+
allow_sensitive_data_access: Whether to allow access to sensitive data (default: False)
|
|
56
|
+
"""
|
|
57
|
+
self.mcp = mcp
|
|
58
|
+
self.client_cache = K8sClientCache()
|
|
59
|
+
self.allow_write = allow_write
|
|
60
|
+
self.allow_sensitive_data_access = allow_sensitive_data_access
|
|
61
|
+
|
|
62
|
+
# Register tools
|
|
63
|
+
self.mcp.tool(name='list_k8s_resources')(self.list_k8s_resources)
|
|
64
|
+
self.mcp.tool(name='get_pod_logs')(self.get_pod_logs)
|
|
65
|
+
self.mcp.tool(name='get_k8s_events')(self.get_k8s_events)
|
|
66
|
+
self.mcp.tool(name='list_api_versions')(self.list_api_versions)
|
|
67
|
+
self.mcp.tool(name='manage_k8s_resource')(self.manage_k8s_resource)
|
|
68
|
+
self.mcp.tool(name='apply_yaml')(self.apply_yaml)
|
|
69
|
+
self.mcp.tool(name='generate_app_manifest')(self.generate_app_manifest)
|
|
70
|
+
|
|
71
|
+
def get_client(self, cluster_name: str) -> K8sApis:
|
|
72
|
+
"""Get a Kubernetes client for the specified cluster.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
cluster_name: Name of the EKS cluster
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
K8sApis instance
|
|
79
|
+
|
|
80
|
+
Raises:
|
|
81
|
+
ValueError: If the cluster credentials are invalid
|
|
82
|
+
Exception: If there's an error getting the cluster credentials
|
|
83
|
+
"""
|
|
84
|
+
return self.client_cache.get_client(cluster_name)
|
|
85
|
+
|
|
86
|
+
async def apply_yaml(
|
|
87
|
+
self,
|
|
88
|
+
ctx: Context,
|
|
89
|
+
yaml_path: str = Field(
|
|
90
|
+
...,
|
|
91
|
+
description="""Absolute path to the YAML file to apply.
|
|
92
|
+
IMPORTANT: Must be an absolute path (e.g., '/home/user/manifests/app.yaml') as the MCP client and server might not run from the same location.""",
|
|
93
|
+
),
|
|
94
|
+
cluster_name: str = Field(
|
|
95
|
+
...,
|
|
96
|
+
description='Name of the EKS cluster where the resources will be created or updated.',
|
|
97
|
+
),
|
|
98
|
+
namespace: str = Field(
|
|
99
|
+
...,
|
|
100
|
+
description='Kubernetes namespace to apply resources to. Will be used for namespaced resources that do not specify a namespace.',
|
|
101
|
+
),
|
|
102
|
+
force: bool = Field(
|
|
103
|
+
True,
|
|
104
|
+
description='Whether to update resources if they already exist (similar to kubectl apply). Set to false to only create new resources.',
|
|
105
|
+
),
|
|
106
|
+
) -> ApplyYamlResponse:
|
|
107
|
+
"""Apply a Kubernetes YAML from a local file.
|
|
108
|
+
|
|
109
|
+
This tool applies Kubernetes resources defined in a YAML file to an EKS cluster,
|
|
110
|
+
similar to the `kubectl apply` command. It supports multi-document YAML files
|
|
111
|
+
and can create or update resources, useful for deploying applications, creating
|
|
112
|
+
Kubernetes resources, and applying complete application stacks.
|
|
113
|
+
|
|
114
|
+
## Requirements
|
|
115
|
+
- The server must be run with the `--allow-write` flag
|
|
116
|
+
- The YAML file must exist and be accessible to the server
|
|
117
|
+
- The path must be absolute (e.g., '/home/user/manifests/app.yaml')
|
|
118
|
+
- The EKS cluster must exist and be accessible
|
|
119
|
+
|
|
120
|
+
## Response Information
|
|
121
|
+
The response includes the number of resources created, number of resources
|
|
122
|
+
updated (when force=True), and whether force was applied.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
ctx: MCP context
|
|
126
|
+
yaml_path: Absolute path to the YAML file to apply
|
|
127
|
+
cluster_name: Name of the EKS cluster
|
|
128
|
+
namespace: Default namespace to use for resources
|
|
129
|
+
force: Whether to update resources if they already exist (like kubectl apply)
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
ApplyYamlResponse with operation result
|
|
133
|
+
"""
|
|
134
|
+
try:
|
|
135
|
+
# Validate that the path is absolute
|
|
136
|
+
if not os.path.isabs(yaml_path):
|
|
137
|
+
error_msg = f'Path must be absolute: {yaml_path}'
|
|
138
|
+
log_with_request_id(ctx, LogLevel.ERROR, error_msg)
|
|
139
|
+
return ApplyYamlResponse(
|
|
140
|
+
isError=True,
|
|
141
|
+
content=[TextContent(type='text', text=error_msg)],
|
|
142
|
+
force_applied=force,
|
|
143
|
+
resources_created=0,
|
|
144
|
+
resources_updated=0,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
# Get Kubernetes client for the cluster
|
|
148
|
+
k8s_client = self.get_client(cluster_name)
|
|
149
|
+
|
|
150
|
+
# Read the YAML content from the local file
|
|
151
|
+
log_with_request_id(ctx, LogLevel.INFO, f'Reading YAML content from file: {yaml_path}')
|
|
152
|
+
|
|
153
|
+
try:
|
|
154
|
+
with open(yaml_path, 'r') as yaml_file:
|
|
155
|
+
yaml_content = yaml_file.read()
|
|
156
|
+
except FileNotFoundError:
|
|
157
|
+
error_msg = f'YAML file not found: {yaml_path}'
|
|
158
|
+
log_with_request_id(ctx, LogLevel.ERROR, error_msg)
|
|
159
|
+
return ApplyYamlResponse(
|
|
160
|
+
isError=True,
|
|
161
|
+
content=[TextContent(type='text', text=error_msg)],
|
|
162
|
+
force_applied=force,
|
|
163
|
+
resources_created=0,
|
|
164
|
+
resources_updated=0,
|
|
165
|
+
)
|
|
166
|
+
except IOError as e:
|
|
167
|
+
error_msg = f'Error reading YAML file {yaml_path}: {str(e)}'
|
|
168
|
+
log_with_request_id(ctx, LogLevel.ERROR, error_msg)
|
|
169
|
+
return ApplyYamlResponse(
|
|
170
|
+
isError=True,
|
|
171
|
+
content=[TextContent(type='text', text=error_msg)],
|
|
172
|
+
force_applied=force,
|
|
173
|
+
resources_created=0,
|
|
174
|
+
resources_updated=0,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
# Parse YAML documents
|
|
178
|
+
yaml_objects = list(yaml.safe_load_all(yaml_content))
|
|
179
|
+
yaml_objects = [doc for doc in yaml_objects if doc] # Filter out None/empty documents
|
|
180
|
+
|
|
181
|
+
log_with_request_id(
|
|
182
|
+
ctx, LogLevel.INFO, f'Found {len(yaml_objects)} resources in the manifest'
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
# Apply all resources using our custom implementation
|
|
186
|
+
try:
|
|
187
|
+
# Apply the YAML objects
|
|
188
|
+
results, created_count, updated_count = k8s_client.apply_from_yaml(
|
|
189
|
+
yaml_objects=yaml_objects,
|
|
190
|
+
namespace=namespace,
|
|
191
|
+
force=force,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
# If we get here, all resources were applied successfully
|
|
195
|
+
success_msg = (
|
|
196
|
+
f'Successfully applied all resources from YAML file {yaml_path} '
|
|
197
|
+
f'({created_count} created, {updated_count} updated)'
|
|
198
|
+
)
|
|
199
|
+
log_with_request_id(ctx, LogLevel.INFO, success_msg)
|
|
200
|
+
|
|
201
|
+
return ApplyYamlResponse(
|
|
202
|
+
isError=False,
|
|
203
|
+
content=[TextContent(type='text', text=success_msg)],
|
|
204
|
+
force_applied=force,
|
|
205
|
+
resources_created=created_count,
|
|
206
|
+
resources_updated=updated_count,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
except Exception as e:
|
|
210
|
+
# Any exception means the operation failed
|
|
211
|
+
error_msg = f'Failed to apply YAML from file {yaml_path}: {str(e)}'
|
|
212
|
+
log_with_request_id(ctx, LogLevel.ERROR, error_msg)
|
|
213
|
+
|
|
214
|
+
return ApplyYamlResponse(
|
|
215
|
+
isError=True,
|
|
216
|
+
content=[TextContent(type='text', text=error_msg)],
|
|
217
|
+
force_applied=force,
|
|
218
|
+
resources_created=0,
|
|
219
|
+
resources_updated=0,
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
except Exception as e:
|
|
223
|
+
error_msg = f'Error applying YAML from file: {str(e)}'
|
|
224
|
+
log_with_request_id(ctx, LogLevel.ERROR, error_msg)
|
|
225
|
+
|
|
226
|
+
return ApplyYamlResponse(
|
|
227
|
+
isError=True,
|
|
228
|
+
content=[TextContent(type='text', text=error_msg)],
|
|
229
|
+
force_applied=force,
|
|
230
|
+
resources_created=0,
|
|
231
|
+
resources_updated=0,
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
def filter_null_values(self, data: Any) -> Any:
|
|
235
|
+
"""Recursively filter out null values from dictionaries and lists.
|
|
236
|
+
|
|
237
|
+
Args:
|
|
238
|
+
data: The data structure to filter (dict, list, or primitive)
|
|
239
|
+
|
|
240
|
+
Returns:
|
|
241
|
+
The filtered data structure with null values removed
|
|
242
|
+
"""
|
|
243
|
+
if isinstance(data, dict):
|
|
244
|
+
return {k: self.filter_null_values(v) for k, v in data.items() if v is not None}
|
|
245
|
+
elif isinstance(data, list):
|
|
246
|
+
return [self.filter_null_values(item) for item in data if item is not None]
|
|
247
|
+
else:
|
|
248
|
+
return data
|
|
249
|
+
|
|
250
|
+
def remove_managed_fields(self, resource: Dict[str, Any]) -> Dict[str, Any]:
|
|
251
|
+
"""Remove metadata.managed_fields from a Kubernetes resource.
|
|
252
|
+
|
|
253
|
+
Args:
|
|
254
|
+
resource: The Kubernetes resource dictionary
|
|
255
|
+
|
|
256
|
+
Returns:
|
|
257
|
+
The resource with metadata.managed_fields removed
|
|
258
|
+
"""
|
|
259
|
+
if (
|
|
260
|
+
isinstance(resource, dict)
|
|
261
|
+
and 'metadata' in resource
|
|
262
|
+
and isinstance(resource['metadata'], dict)
|
|
263
|
+
):
|
|
264
|
+
# Dynamic client uses camelCase
|
|
265
|
+
if 'managedFields' in resource['metadata']:
|
|
266
|
+
resource['metadata'].pop('managedFields')
|
|
267
|
+
return resource
|
|
268
|
+
|
|
269
|
+
def cleanup_resource_response(self, resource: Any) -> Any:
|
|
270
|
+
"""Clean up a Kubernetes resource response by removing managed fields and null values.
|
|
271
|
+
|
|
272
|
+
This method:
|
|
273
|
+
1. Removes metadata.managed_fields which is typically large and not useful
|
|
274
|
+
2. Recursively removes null values to reduce response size
|
|
275
|
+
|
|
276
|
+
Args:
|
|
277
|
+
resource: The Kubernetes resource to clean up
|
|
278
|
+
|
|
279
|
+
Returns:
|
|
280
|
+
The cleaned up resource
|
|
281
|
+
"""
|
|
282
|
+
# First remove managed fields
|
|
283
|
+
resource = self.remove_managed_fields(resource)
|
|
284
|
+
|
|
285
|
+
# Then filter out null values
|
|
286
|
+
return self.filter_null_values(resource)
|
|
287
|
+
|
|
288
|
+
async def manage_k8s_resource(
|
|
289
|
+
self,
|
|
290
|
+
ctx: Context,
|
|
291
|
+
operation: str = Field(
|
|
292
|
+
...,
|
|
293
|
+
description="""Operation to perform on the resource. Valid values:
|
|
294
|
+
- create: Create a new resource
|
|
295
|
+
- replace: Replace an existing resource
|
|
296
|
+
- patch: Update specific fields of an existing resource
|
|
297
|
+
- delete: Delete an existing resource
|
|
298
|
+
- read: Get details of an existing resource
|
|
299
|
+
Use list_k8s_resources for listing multiple resources.""",
|
|
300
|
+
),
|
|
301
|
+
cluster_name: str = Field(
|
|
302
|
+
...,
|
|
303
|
+
description='Name of the EKS cluster where the resource is located or will be created.',
|
|
304
|
+
),
|
|
305
|
+
kind: str = Field(
|
|
306
|
+
...,
|
|
307
|
+
description='Kind of the Kubernetes resource (e.g., "Pod", "Service", "Deployment").',
|
|
308
|
+
),
|
|
309
|
+
api_version: str = Field(
|
|
310
|
+
...,
|
|
311
|
+
description='API version of the Kubernetes resource (e.g., "v1", "apps/v1", "networking.k8s.io/v1").',
|
|
312
|
+
),
|
|
313
|
+
name: Optional[str] = Field(
|
|
314
|
+
None,
|
|
315
|
+
description='Name of the Kubernetes resource. Required for all operations except create (where it can be specified in the body).',
|
|
316
|
+
),
|
|
317
|
+
namespace: Optional[str] = Field(
|
|
318
|
+
None,
|
|
319
|
+
description="""Namespace of the Kubernetes resource. Required for namespaced resources.
|
|
320
|
+
Not required for cluster-scoped resources (like Nodes, PersistentVolumes).""",
|
|
321
|
+
),
|
|
322
|
+
body: Optional[Dict[str, Any]] = Field(
|
|
323
|
+
None,
|
|
324
|
+
description="""Resource definition as a dictionary. Required for create, replace, and patch operations.
|
|
325
|
+
For create and replace, this should be a complete resource definition.
|
|
326
|
+
For patch, this should contain only the fields to update.""",
|
|
327
|
+
),
|
|
328
|
+
) -> KubernetesResourceResponse:
|
|
329
|
+
"""Manage a single Kubernetes resource with various operations.
|
|
330
|
+
|
|
331
|
+
This tool provides complete CRUD (Create, Read, Update, Delete) operations
|
|
332
|
+
for Kubernetes resources in an EKS cluster. It supports all resource types
|
|
333
|
+
and allows for precise control over individual resources, enabling you to create
|
|
334
|
+
custom resources, update specific fields, read detailed information, and delete
|
|
335
|
+
resources that are no longer needed.
|
|
336
|
+
|
|
337
|
+
## Requirements
|
|
338
|
+
- The server must be run with the `--allow-write` flag for mutating operations
|
|
339
|
+
- The server must be run with the `--allow-sensitive-data-access` flag for Secret resources
|
|
340
|
+
- The EKS cluster must exist and be accessible
|
|
341
|
+
|
|
342
|
+
## Operations
|
|
343
|
+
- **create**: Create a new resource with the provided definition
|
|
344
|
+
- **replace**: Replace an existing resource with a new definition
|
|
345
|
+
- **patch**: Update specific fields of an existing resource
|
|
346
|
+
- **delete**: Remove an existing resource
|
|
347
|
+
- **read**: Get details of an existing resource
|
|
348
|
+
|
|
349
|
+
## Usage Tips
|
|
350
|
+
- Use list_api_versions to find available API versions
|
|
351
|
+
- For namespaced resources, always provide the namespace
|
|
352
|
+
- When creating resources, ensure the name in the body matches the name parameter
|
|
353
|
+
- For patch operations, only include the fields you want to update
|
|
354
|
+
|
|
355
|
+
Args:
|
|
356
|
+
ctx: MCP context
|
|
357
|
+
operation: Operation to perform (create, replace, patch, delete, read)
|
|
358
|
+
cluster_name: Name of the EKS cluster
|
|
359
|
+
kind: Kind of the Kubernetes resource (e.g., 'Pod', 'Service')
|
|
360
|
+
api_version: API version of the Kubernetes resource (e.g., 'v1', 'apps/v1')
|
|
361
|
+
name: Name of the Kubernetes resource
|
|
362
|
+
namespace: Namespace of the Kubernetes resource (optional)
|
|
363
|
+
body: Resource definition
|
|
364
|
+
|
|
365
|
+
Returns:
|
|
366
|
+
KubernetesResourceResponse with operation result
|
|
367
|
+
"""
|
|
368
|
+
try:
|
|
369
|
+
# Convert string operation to enum
|
|
370
|
+
try:
|
|
371
|
+
operation_enum = Operation(operation)
|
|
372
|
+
except ValueError:
|
|
373
|
+
valid_ops = ', '.join([op.value for op in Operation])
|
|
374
|
+
error_msg = f'Invalid operation: {operation}. Valid operations are: {valid_ops}'
|
|
375
|
+
log_with_request_id(ctx, LogLevel.ERROR, error_msg)
|
|
376
|
+
return KubernetesResourceResponse(
|
|
377
|
+
isError=True,
|
|
378
|
+
content=[TextContent(type='text', text=error_msg)],
|
|
379
|
+
kind=kind,
|
|
380
|
+
name=name or '',
|
|
381
|
+
namespace=namespace,
|
|
382
|
+
api_version=api_version,
|
|
383
|
+
operation=operation,
|
|
384
|
+
resource=None,
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
# Check if write access is disabled and trying to perform a mutating operation
|
|
388
|
+
if not self.allow_write and operation_enum not in [Operation.READ]:
|
|
389
|
+
error_msg = f'Operation {operation} is not allowed without write access'
|
|
390
|
+
log_with_request_id(ctx, LogLevel.ERROR, error_msg)
|
|
391
|
+
return KubernetesResourceResponse(
|
|
392
|
+
isError=True,
|
|
393
|
+
content=[TextContent(type='text', text=error_msg)],
|
|
394
|
+
kind=kind,
|
|
395
|
+
name=name or '',
|
|
396
|
+
namespace=namespace,
|
|
397
|
+
api_version=api_version,
|
|
398
|
+
operation=operation,
|
|
399
|
+
resource=None,
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
# Check if sensitive data access is disabled and trying to read Secret resources
|
|
403
|
+
if (
|
|
404
|
+
not self.allow_sensitive_data_access
|
|
405
|
+
and kind.lower() == 'secret'
|
|
406
|
+
and operation_enum in [Operation.READ]
|
|
407
|
+
):
|
|
408
|
+
error_msg = (
|
|
409
|
+
'Access to Kubernetes Secrets requires --allow-sensitive-data-access flag'
|
|
410
|
+
)
|
|
411
|
+
log_with_request_id(ctx, LogLevel.ERROR, error_msg)
|
|
412
|
+
return KubernetesResourceResponse(
|
|
413
|
+
isError=True,
|
|
414
|
+
content=[TextContent(type='text', text=error_msg)],
|
|
415
|
+
kind=kind,
|
|
416
|
+
name=name or '',
|
|
417
|
+
namespace=namespace,
|
|
418
|
+
api_version=api_version,
|
|
419
|
+
operation=operation,
|
|
420
|
+
resource=None,
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
# Get Kubernetes client for the cluster
|
|
424
|
+
k8s_client = self.get_client(cluster_name)
|
|
425
|
+
|
|
426
|
+
# Call the manage_resource method
|
|
427
|
+
response = k8s_client.manage_resource(
|
|
428
|
+
operation_enum,
|
|
429
|
+
kind,
|
|
430
|
+
api_version,
|
|
431
|
+
name=name,
|
|
432
|
+
namespace=namespace,
|
|
433
|
+
body=body,
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
# Format resource name for logging
|
|
437
|
+
resource_name = f'{namespace + "/" if namespace else ""}{name}'
|
|
438
|
+
|
|
439
|
+
# Log success
|
|
440
|
+
operation_past_tense = {
|
|
441
|
+
Operation.CREATE.value: 'created',
|
|
442
|
+
Operation.REPLACE.value: 'replaced',
|
|
443
|
+
Operation.PATCH.value: 'patched',
|
|
444
|
+
Operation.DELETE.value: 'deleted',
|
|
445
|
+
Operation.READ.value: 'retrieved',
|
|
446
|
+
}[operation_enum.value]
|
|
447
|
+
|
|
448
|
+
log_with_request_id(
|
|
449
|
+
ctx, LogLevel.INFO, f'{operation_past_tense.capitalize()} {kind} {resource_name}'
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
# For read operation, convert response to dict and clean up the response
|
|
453
|
+
resource_data = None
|
|
454
|
+
if operation_enum == Operation.READ:
|
|
455
|
+
resource_data = self.cleanup_resource_response(response.to_dict())
|
|
456
|
+
log_with_request_id(
|
|
457
|
+
ctx,
|
|
458
|
+
LogLevel.INFO,
|
|
459
|
+
f'Cleaned up resource response for {kind} {resource_name}',
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
# Return success response
|
|
463
|
+
return KubernetesResourceResponse(
|
|
464
|
+
isError=False,
|
|
465
|
+
content=[
|
|
466
|
+
TextContent(
|
|
467
|
+
type='text',
|
|
468
|
+
text=f'Successfully {operation_past_tense} {kind} {resource_name}',
|
|
469
|
+
)
|
|
470
|
+
],
|
|
471
|
+
kind=kind,
|
|
472
|
+
name=name or '',
|
|
473
|
+
namespace=namespace,
|
|
474
|
+
api_version=api_version,
|
|
475
|
+
operation=operation,
|
|
476
|
+
resource=resource_data,
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
except Exception as e:
|
|
480
|
+
# Log error
|
|
481
|
+
resource_name = f'{namespace + "/" if namespace else ""}{name or ""}'
|
|
482
|
+
error_msg = f'Failed to {operation} {kind} {resource_name}: {str(e)}'
|
|
483
|
+
log_with_request_id(ctx, LogLevel.ERROR, error_msg)
|
|
484
|
+
|
|
485
|
+
# Return error response
|
|
486
|
+
return KubernetesResourceResponse(
|
|
487
|
+
isError=True,
|
|
488
|
+
content=[TextContent(type='text', text=error_msg)],
|
|
489
|
+
kind=kind,
|
|
490
|
+
name=name or '',
|
|
491
|
+
namespace=namespace,
|
|
492
|
+
api_version=api_version,
|
|
493
|
+
operation=operation,
|
|
494
|
+
resource=None,
|
|
495
|
+
)
|
|
496
|
+
|
|
497
|
+
async def list_k8s_resources(
|
|
498
|
+
self,
|
|
499
|
+
ctx: Context,
|
|
500
|
+
cluster_name: str = Field(
|
|
501
|
+
..., description='Name of the EKS cluster where the resources are located.'
|
|
502
|
+
),
|
|
503
|
+
kind: str = Field(
|
|
504
|
+
...,
|
|
505
|
+
description="""Kind of the Kubernetes resources to list (e.g., 'Pod', 'Service', 'Deployment').
|
|
506
|
+
Use the list_api_versions tool to find available resource kinds.""",
|
|
507
|
+
),
|
|
508
|
+
api_version: str = Field(
|
|
509
|
+
...,
|
|
510
|
+
description="""API version of the Kubernetes resources (e.g., 'v1', 'apps/v1', 'networking.k8s.io/v1').
|
|
511
|
+
Use the list_api_versions tool to find available API versions.""",
|
|
512
|
+
),
|
|
513
|
+
namespace: Optional[str] = Field(
|
|
514
|
+
None,
|
|
515
|
+
description="""Namespace of the Kubernetes resources to list.
|
|
516
|
+
If not provided, resources will be listed across all namespaces (for namespaced resources).""",
|
|
517
|
+
),
|
|
518
|
+
label_selector: Optional[str] = Field(
|
|
519
|
+
None,
|
|
520
|
+
description="""Label selector to filter resources (e.g., 'app=nginx,tier=frontend').
|
|
521
|
+
Uses the same syntax as kubectl's --selector flag.""",
|
|
522
|
+
),
|
|
523
|
+
field_selector: Optional[str] = Field(
|
|
524
|
+
None,
|
|
525
|
+
description="""Field selector to filter resources (e.g., 'metadata.name=my-pod,status.phase=Running').
|
|
526
|
+
Uses the same syntax as kubectl's --field-selector flag.""",
|
|
527
|
+
),
|
|
528
|
+
) -> KubernetesResourceListResponse:
|
|
529
|
+
"""List Kubernetes resources of a specific kind.
|
|
530
|
+
|
|
531
|
+
This tool lists Kubernetes resources of a specified kind in an EKS cluster,
|
|
532
|
+
with options to filter by namespace, labels, and fields. It returns a summary
|
|
533
|
+
of each resource including name, namespace, creation time, and metadata, useful
|
|
534
|
+
for listing pods in a namespace, finding services with specific labels, or
|
|
535
|
+
checking resources in a specific state.
|
|
536
|
+
|
|
537
|
+
## Response Information
|
|
538
|
+
The response includes a summary of each resource with name, namespace, creation timestamp,
|
|
539
|
+
labels, and annotations.
|
|
540
|
+
|
|
541
|
+
## Usage Tips
|
|
542
|
+
- Use the list_api_versions tool first to find available API versions
|
|
543
|
+
- For non-namespaced resources (like Nodes), the namespace parameter is ignored
|
|
544
|
+
- Combine label and field selectors for more precise filtering
|
|
545
|
+
- Results are summarized to avoid overwhelming responses
|
|
546
|
+
|
|
547
|
+
Args:
|
|
548
|
+
ctx: MCP context
|
|
549
|
+
cluster_name: Name of the EKS cluster
|
|
550
|
+
kind: Kind of the Kubernetes resources (e.g., 'Pod', 'Service')
|
|
551
|
+
api_version: API version of the Kubernetes resources (e.g., 'v1', 'apps/v1')
|
|
552
|
+
namespace: Namespace of the Kubernetes resources (optional)
|
|
553
|
+
label_selector: Label selector to filter resources (optional)
|
|
554
|
+
field_selector: Field selector to filter resources (optional)
|
|
555
|
+
|
|
556
|
+
Returns:
|
|
557
|
+
KubernetesResourceListResponse with operation result
|
|
558
|
+
"""
|
|
559
|
+
try:
|
|
560
|
+
# Get Kubernetes client for the cluster
|
|
561
|
+
k8s_client = self.get_client(cluster_name)
|
|
562
|
+
|
|
563
|
+
# List resources
|
|
564
|
+
response = k8s_client.list_resources(
|
|
565
|
+
kind,
|
|
566
|
+
api_version,
|
|
567
|
+
namespace=namespace,
|
|
568
|
+
label_selector=label_selector,
|
|
569
|
+
field_selector=field_selector,
|
|
570
|
+
)
|
|
571
|
+
|
|
572
|
+
# Extract summaries from items and clean up the responses
|
|
573
|
+
summaries = []
|
|
574
|
+
for item in response.items:
|
|
575
|
+
item_dict = self.cleanup_resource_response(item.to_dict())
|
|
576
|
+
metadata = item_dict.get('metadata', {})
|
|
577
|
+
|
|
578
|
+
# Dynamic client uses camelCase field names
|
|
579
|
+
creation_timestamp = metadata.get('creationTimestamp')
|
|
580
|
+
if creation_timestamp is not None:
|
|
581
|
+
creation_timestamp = str(creation_timestamp)
|
|
582
|
+
|
|
583
|
+
summary = ResourceSummary(
|
|
584
|
+
name=metadata.get('name', ''),
|
|
585
|
+
namespace=metadata.get('namespace'),
|
|
586
|
+
creation_timestamp=creation_timestamp,
|
|
587
|
+
labels=metadata.get('labels'),
|
|
588
|
+
annotations=metadata.get('annotations'),
|
|
589
|
+
)
|
|
590
|
+
summaries.append(summary)
|
|
591
|
+
|
|
592
|
+
log_with_request_id(
|
|
593
|
+
ctx, LogLevel.INFO, f'Cleaned up resource responses for {kind} resources'
|
|
594
|
+
)
|
|
595
|
+
|
|
596
|
+
# Log success
|
|
597
|
+
resource_location = f'in {namespace + "/" if namespace else ""}all namespaces'
|
|
598
|
+
log_with_request_id(
|
|
599
|
+
ctx, LogLevel.INFO, f'Listed {len(summaries)} {kind} resources {resource_location}'
|
|
600
|
+
)
|
|
601
|
+
|
|
602
|
+
# Return success response
|
|
603
|
+
return KubernetesResourceListResponse(
|
|
604
|
+
isError=False,
|
|
605
|
+
content=[
|
|
606
|
+
TextContent(
|
|
607
|
+
type='text',
|
|
608
|
+
text=f'Successfully listed {len(summaries)} {kind} resources {resource_location}',
|
|
609
|
+
)
|
|
610
|
+
],
|
|
611
|
+
kind=kind,
|
|
612
|
+
api_version=api_version,
|
|
613
|
+
namespace=namespace,
|
|
614
|
+
count=len(summaries),
|
|
615
|
+
items=summaries,
|
|
616
|
+
)
|
|
617
|
+
|
|
618
|
+
except Exception as e:
|
|
619
|
+
# Log error
|
|
620
|
+
error_msg = f'Failed to list {kind} resources: {str(e)}'
|
|
621
|
+
log_with_request_id(ctx, LogLevel.ERROR, error_msg)
|
|
622
|
+
|
|
623
|
+
# Return error response
|
|
624
|
+
return KubernetesResourceListResponse(
|
|
625
|
+
isError=True,
|
|
626
|
+
content=[TextContent(type='text', text=error_msg)],
|
|
627
|
+
kind=kind,
|
|
628
|
+
api_version=api_version,
|
|
629
|
+
namespace=namespace,
|
|
630
|
+
count=0,
|
|
631
|
+
items=[],
|
|
632
|
+
)
|
|
633
|
+
|
|
634
|
+
async def generate_app_manifest(
|
|
635
|
+
self,
|
|
636
|
+
ctx: Context,
|
|
637
|
+
app_name: str = Field(
|
|
638
|
+
...,
|
|
639
|
+
description='Name of the application. Used for deployment and service names, and for labels.',
|
|
640
|
+
),
|
|
641
|
+
image_uri: str = Field(
|
|
642
|
+
...,
|
|
643
|
+
description="""Full ECR image URI with tag (e.g., 123456789012.dkr.ecr.region.amazonaws.com/repo:tag).
|
|
644
|
+
Must include the full repository path and tag.""",
|
|
645
|
+
),
|
|
646
|
+
output_dir: str = Field(
|
|
647
|
+
..., description='Absolute path to the directory to save the manifest file'
|
|
648
|
+
),
|
|
649
|
+
port: int = Field(80, description='Container port that the application listens on'),
|
|
650
|
+
replicas: int = Field(2, description='Number of replicas to deploy'),
|
|
651
|
+
cpu: str = Field(
|
|
652
|
+
'100m',
|
|
653
|
+
description='CPU request for each container (e.g., "100m" for 0.1 CPU cores, "500m" for half a core).',
|
|
654
|
+
),
|
|
655
|
+
memory: str = Field(
|
|
656
|
+
'128Mi',
|
|
657
|
+
description='Memory request for each container (e.g., "128Mi" for 128 MiB, "1Gi" for 1 GiB).',
|
|
658
|
+
),
|
|
659
|
+
namespace: str = Field(
|
|
660
|
+
'default',
|
|
661
|
+
description='Kubernetes namespace to deploy the application to. Default: "default"',
|
|
662
|
+
),
|
|
663
|
+
load_balancer_scheme: str = Field(
|
|
664
|
+
'internal',
|
|
665
|
+
description='AWS load balancer scheme. Options: "internal" (private VPC only) or "internet-facing" (public access).',
|
|
666
|
+
),
|
|
667
|
+
) -> GenerateAppManifestResponse:
|
|
668
|
+
"""Generate Kubernetes manifest for a deployment and service.
|
|
669
|
+
|
|
670
|
+
This tool generates Kubernetes manifests for deploying an application to an EKS cluster,
|
|
671
|
+
creating both a Deployment and a LoadBalancer Service. The generated manifest can be
|
|
672
|
+
applied to a cluster using the apply_yaml tool, useful for deploying containerized
|
|
673
|
+
applications, creating load-balanced services, and standardizing deployment configurations.
|
|
674
|
+
|
|
675
|
+
## Requirements
|
|
676
|
+
- The server must be run with the `--allow-write` flag
|
|
677
|
+
|
|
678
|
+
## Generated Resources
|
|
679
|
+
- **Deployment**: Manages the application pods with specified replicas and resource requests
|
|
680
|
+
- **Service**: LoadBalancer type service that exposes the application externally
|
|
681
|
+
|
|
682
|
+
## Usage Tips
|
|
683
|
+
- Use 2 or more replicas for production workloads
|
|
684
|
+
- Set appropriate resource requests based on application needs
|
|
685
|
+
- Use internal load balancers for services that should only be accessible within the VPC
|
|
686
|
+
- The generated manifest can be modified before applying if needed
|
|
687
|
+
|
|
688
|
+
Args:
|
|
689
|
+
ctx: MCP context
|
|
690
|
+
app_name: Name of the application (used for deployment and service names)
|
|
691
|
+
image_uri: Full ECR image URI with tag
|
|
692
|
+
port: Container port that the application listens on
|
|
693
|
+
replicas: Number of replicas to deploy
|
|
694
|
+
cpu: CPU request for each container
|
|
695
|
+
memory: Memory request for each container
|
|
696
|
+
namespace: Kubernetes namespace to deploy to
|
|
697
|
+
load_balancer_scheme: AWS load balancer scheme (internal or internet-facing)
|
|
698
|
+
output_dir: Directory to save the manifest file
|
|
699
|
+
|
|
700
|
+
Returns:
|
|
701
|
+
GenerateAppManifestResponse: The complete Kubernetes manifest content and output file path
|
|
702
|
+
"""
|
|
703
|
+
try:
|
|
704
|
+
# Check if write access is disabled
|
|
705
|
+
if not self.allow_write:
|
|
706
|
+
error_msg = 'Operation generate_app_manifest is not allowed without write access'
|
|
707
|
+
log_with_request_id(ctx, LogLevel.ERROR, error_msg)
|
|
708
|
+
return GenerateAppManifestResponse(
|
|
709
|
+
isError=True,
|
|
710
|
+
content=[TextContent(type='text', text=error_msg)],
|
|
711
|
+
output_file_path='',
|
|
712
|
+
)
|
|
713
|
+
|
|
714
|
+
# Validate that the path is absolute
|
|
715
|
+
if not os.path.isabs(output_dir):
|
|
716
|
+
error_msg = f'Output directory path must be absolute: {output_dir}'
|
|
717
|
+
log_with_request_id(ctx, LogLevel.ERROR, error_msg)
|
|
718
|
+
return GenerateAppManifestResponse(
|
|
719
|
+
isError=True,
|
|
720
|
+
content=[TextContent(type='text', text=error_msg)],
|
|
721
|
+
output_file_path='',
|
|
722
|
+
)
|
|
723
|
+
|
|
724
|
+
log_with_request_id(
|
|
725
|
+
ctx,
|
|
726
|
+
LogLevel.INFO,
|
|
727
|
+
f'Generating YAML for application {app_name} using image {image_uri}',
|
|
728
|
+
)
|
|
729
|
+
|
|
730
|
+
# List of template files to process
|
|
731
|
+
template_files = ['deployment.yaml', 'service.yaml']
|
|
732
|
+
|
|
733
|
+
# Prepare template values
|
|
734
|
+
template_values = {
|
|
735
|
+
'APP_NAME': app_name,
|
|
736
|
+
'NAMESPACE': namespace,
|
|
737
|
+
'REPLICAS': str(replicas), # Convert to string for template substitution
|
|
738
|
+
'IMAGE_URI': image_uri,
|
|
739
|
+
'PORT': str(port),
|
|
740
|
+
'CPU': cpu,
|
|
741
|
+
'MEMORY': memory,
|
|
742
|
+
'LOAD_BALANCER_SCHEME': load_balancer_scheme,
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
# Get the combined manifest using the template files
|
|
746
|
+
combined_yaml = self._load_yaml_template(template_files, template_values)
|
|
747
|
+
|
|
748
|
+
# Ensure output directory exists
|
|
749
|
+
os.makedirs(output_dir, exist_ok=True)
|
|
750
|
+
|
|
751
|
+
# Define output file path (using absolute path)
|
|
752
|
+
output_file_path = os.path.abspath(
|
|
753
|
+
os.path.join(output_dir, f'{app_name}-manifest.yaml')
|
|
754
|
+
)
|
|
755
|
+
|
|
756
|
+
# Write the manifest to the output file
|
|
757
|
+
with open(output_file_path, 'w') as f:
|
|
758
|
+
f.write(combined_yaml)
|
|
759
|
+
|
|
760
|
+
success_message = (
|
|
761
|
+
f'Successfully generated YAML for {app_name} application with image {image_uri} '
|
|
762
|
+
f'and saved to {output_file_path}'
|
|
763
|
+
)
|
|
764
|
+
|
|
765
|
+
log_with_request_id(ctx, LogLevel.INFO, success_message)
|
|
766
|
+
|
|
767
|
+
return GenerateAppManifestResponse(
|
|
768
|
+
isError=False,
|
|
769
|
+
content=[TextContent(type='text', text=success_message)],
|
|
770
|
+
output_file_path=output_file_path,
|
|
771
|
+
)
|
|
772
|
+
|
|
773
|
+
except Exception as e:
|
|
774
|
+
error_message = f'Failed to generate YAML: {str(e)}'
|
|
775
|
+
log_with_request_id(ctx, LogLevel.ERROR, error_message)
|
|
776
|
+
|
|
777
|
+
return GenerateAppManifestResponse(
|
|
778
|
+
isError=True,
|
|
779
|
+
content=[TextContent(type='text', text=error_message)],
|
|
780
|
+
output_file_path='',
|
|
781
|
+
)
|
|
782
|
+
|
|
783
|
+
def _load_yaml_template(self, template_files: list, values: Dict[str, Any]) -> str:
|
|
784
|
+
"""Load and process Kubernetes template files.
|
|
785
|
+
|
|
786
|
+
Args:
|
|
787
|
+
template_files: List of template filenames to process
|
|
788
|
+
values: Dictionary of values to substitute into the templates
|
|
789
|
+
|
|
790
|
+
Returns:
|
|
791
|
+
A string containing the combined YAML content with variables substituted
|
|
792
|
+
"""
|
|
793
|
+
templates_dir = os.path.join(os.path.dirname(__file__), 'templates', 'k8s-templates')
|
|
794
|
+
template_contents = []
|
|
795
|
+
|
|
796
|
+
# Process each template file
|
|
797
|
+
for template_file in template_files:
|
|
798
|
+
template_path = os.path.join(templates_dir, template_file)
|
|
799
|
+
|
|
800
|
+
with open(template_path, 'r') as f:
|
|
801
|
+
content = f.read()
|
|
802
|
+
|
|
803
|
+
# Replace variables in the template
|
|
804
|
+
for key, value in values.items():
|
|
805
|
+
content = content.replace(key, value)
|
|
806
|
+
|
|
807
|
+
template_contents.append(content)
|
|
808
|
+
|
|
809
|
+
# Combine templates into a single YAML document with separator
|
|
810
|
+
return '\n---\n'.join(template_contents)
|
|
811
|
+
|
|
812
|
+
async def get_pod_logs(
|
|
813
|
+
self,
|
|
814
|
+
ctx: Context,
|
|
815
|
+
cluster_name: str = Field(
|
|
816
|
+
..., description='Name of the EKS cluster where the pod is running.'
|
|
817
|
+
),
|
|
818
|
+
namespace: str = Field(..., description='Kubernetes namespace where the pod is located.'),
|
|
819
|
+
pod_name: str = Field(..., description='Name of the pod to retrieve logs from.'),
|
|
820
|
+
container_name: Optional[str] = Field(
|
|
821
|
+
None,
|
|
822
|
+
description='Name of the specific container to get logs from. Required only if the pod contains multiple containers.',
|
|
823
|
+
),
|
|
824
|
+
since_seconds: Optional[int] = Field(
|
|
825
|
+
None,
|
|
826
|
+
description='Only return logs newer than this many seconds. Useful for getting recent logs without retrieving the entire history.',
|
|
827
|
+
),
|
|
828
|
+
tail_lines: int = Field(
|
|
829
|
+
100,
|
|
830
|
+
description='Number of lines to return from the end of the logs. Default: 100. Use higher values for more context.',
|
|
831
|
+
),
|
|
832
|
+
limit_bytes: int = Field(
|
|
833
|
+
10240,
|
|
834
|
+
description='Maximum number of bytes to return. Default: 10KB (10240 bytes). Prevents retrieving extremely large log files.',
|
|
835
|
+
),
|
|
836
|
+
) -> PodLogsResponse:
|
|
837
|
+
"""Get logs from a pod in a Kubernetes cluster.
|
|
838
|
+
|
|
839
|
+
This tool retrieves logs from a specified pod in an EKS cluster, with options
|
|
840
|
+
to filter by container, time range, and size. It's useful for debugging application
|
|
841
|
+
issues, monitoring behavior, investigating crashes, and verifying startup configuration.
|
|
842
|
+
|
|
843
|
+
## Requirements
|
|
844
|
+
- The server must be run with the `--allow-sensitive-data-access` flag
|
|
845
|
+
- The pod must exist and be accessible in the specified namespace
|
|
846
|
+
- The EKS cluster must exist and be accessible
|
|
847
|
+
|
|
848
|
+
## Response Information
|
|
849
|
+
The response includes pod name, namespace, container name (if specified),
|
|
850
|
+
and log lines as an array of strings.
|
|
851
|
+
|
|
852
|
+
Args:
|
|
853
|
+
ctx: MCP context
|
|
854
|
+
cluster_name: Name of the EKS cluster
|
|
855
|
+
namespace: Namespace of the pod
|
|
856
|
+
pod_name: Name of the pod
|
|
857
|
+
container_name: Container name (optional, if pod contains more than one container)
|
|
858
|
+
since_seconds: Only return logs newer than this many seconds (optional)
|
|
859
|
+
tail_lines: Number of lines to return from the end of the logs (defaults to 100)
|
|
860
|
+
limit_bytes: Maximum number of bytes to return (defaults to 10KB)
|
|
861
|
+
|
|
862
|
+
Returns:
|
|
863
|
+
PodLogsResponse with pod logs
|
|
864
|
+
"""
|
|
865
|
+
# Check if sensitive data access is disabled
|
|
866
|
+
if not self.allow_sensitive_data_access:
|
|
867
|
+
error_msg = 'Access to pod logs requires --allow-sensitive-data-access flag'
|
|
868
|
+
log_with_request_id(ctx, LogLevel.ERROR, error_msg)
|
|
869
|
+
return PodLogsResponse(
|
|
870
|
+
isError=True,
|
|
871
|
+
content=[TextContent(type='text', text=error_msg)],
|
|
872
|
+
pod_name=pod_name,
|
|
873
|
+
namespace=namespace,
|
|
874
|
+
container_name=container_name,
|
|
875
|
+
log_lines=[],
|
|
876
|
+
)
|
|
877
|
+
|
|
878
|
+
try:
|
|
879
|
+
# Get Kubernetes client for the cluster
|
|
880
|
+
k8s_client = self.get_client(cluster_name)
|
|
881
|
+
|
|
882
|
+
# Get pod logs
|
|
883
|
+
logs = k8s_client.get_pod_logs(
|
|
884
|
+
pod_name=pod_name,
|
|
885
|
+
namespace=namespace,
|
|
886
|
+
container_name=container_name,
|
|
887
|
+
since_seconds=since_seconds,
|
|
888
|
+
tail_lines=tail_lines,
|
|
889
|
+
limit_bytes=limit_bytes,
|
|
890
|
+
)
|
|
891
|
+
|
|
892
|
+
# Split logs into lines
|
|
893
|
+
log_lines = logs.splitlines(keepends=False)
|
|
894
|
+
|
|
895
|
+
# Add an empty string at the end if the logs end with a newline
|
|
896
|
+
if logs.endswith('\n'):
|
|
897
|
+
log_lines.append('')
|
|
898
|
+
|
|
899
|
+
# Format container info for logging
|
|
900
|
+
container_info = f' (container: {container_name})' if container_name else ''
|
|
901
|
+
|
|
902
|
+
# Log success
|
|
903
|
+
log_with_request_id(
|
|
904
|
+
ctx,
|
|
905
|
+
LogLevel.INFO,
|
|
906
|
+
f'Retrieved {len(log_lines)} log lines from pod {namespace}/{pod_name}{container_info}',
|
|
907
|
+
)
|
|
908
|
+
|
|
909
|
+
# Return success response
|
|
910
|
+
return PodLogsResponse(
|
|
911
|
+
isError=False,
|
|
912
|
+
content=[
|
|
913
|
+
TextContent(
|
|
914
|
+
type='text',
|
|
915
|
+
text=f'Successfully retrieved {len(log_lines)} log lines from pod {namespace}/{pod_name}{container_info}',
|
|
916
|
+
)
|
|
917
|
+
],
|
|
918
|
+
pod_name=pod_name,
|
|
919
|
+
namespace=namespace,
|
|
920
|
+
container_name=container_name,
|
|
921
|
+
log_lines=log_lines,
|
|
922
|
+
)
|
|
923
|
+
|
|
924
|
+
except Exception as e:
|
|
925
|
+
# Format container info for error message
|
|
926
|
+
container_info = f' (container: {container_name})' if container_name else ''
|
|
927
|
+
|
|
928
|
+
# Log error
|
|
929
|
+
error_msg = (
|
|
930
|
+
f'Failed to get logs from pod {namespace}/{pod_name}{container_info}: {str(e)}'
|
|
931
|
+
)
|
|
932
|
+
log_with_request_id(ctx, LogLevel.ERROR, error_msg)
|
|
933
|
+
|
|
934
|
+
# Return error response
|
|
935
|
+
return PodLogsResponse(
|
|
936
|
+
isError=True,
|
|
937
|
+
content=[TextContent(type='text', text=error_msg)],
|
|
938
|
+
pod_name=pod_name,
|
|
939
|
+
namespace=namespace,
|
|
940
|
+
container_name=container_name,
|
|
941
|
+
log_lines=[],
|
|
942
|
+
)
|
|
943
|
+
|
|
944
|
+
async def get_k8s_events(
|
|
945
|
+
self,
|
|
946
|
+
ctx: Context,
|
|
947
|
+
cluster_name: str = Field(
|
|
948
|
+
..., description='Name of the EKS cluster where the resource is located.'
|
|
949
|
+
),
|
|
950
|
+
kind: str = Field(
|
|
951
|
+
...,
|
|
952
|
+
description='Kind of the involved object (e.g., "Pod", "Deployment", "Service"). Must match the resource kind exactly.',
|
|
953
|
+
),
|
|
954
|
+
name: str = Field(..., description='Name of the involved object to get events for.'),
|
|
955
|
+
namespace: Optional[str] = Field(
|
|
956
|
+
None,
|
|
957
|
+
description="""Namespace of the involved object. Required for namespaced resources (like Pods, Deployments).
|
|
958
|
+
Not required for cluster-scoped resources (like Nodes, PersistentVolumes).""",
|
|
959
|
+
),
|
|
960
|
+
) -> EventsResponse:
|
|
961
|
+
"""Get events related to a specific Kubernetes resource.
|
|
962
|
+
|
|
963
|
+
This tool retrieves Kubernetes events related to a specific resource, providing
|
|
964
|
+
detailed information about what has happened to the resource over time. Events
|
|
965
|
+
are useful for troubleshooting pod startup failures, investigating deployment issues,
|
|
966
|
+
understanding resource modifications, and diagnosing scheduling problems.
|
|
967
|
+
|
|
968
|
+
## Requirements
|
|
969
|
+
- The server must be run with the `--allow-sensitive-data-access` flag
|
|
970
|
+
- The resource must exist and be accessible in the specified namespace
|
|
971
|
+
|
|
972
|
+
## Response Information
|
|
973
|
+
The response includes events with timestamps (first and last), occurrence counts,
|
|
974
|
+
messages, reasons, reporting components, and event types (Normal or Warning).
|
|
975
|
+
|
|
976
|
+
## Usage Tips
|
|
977
|
+
- Warning events often indicate problems that need attention
|
|
978
|
+
- Normal events provide information about expected lifecycle operations
|
|
979
|
+
- The count field shows how many times the same event has occurred
|
|
980
|
+
- Recent events are most relevant for current issues
|
|
981
|
+
|
|
982
|
+
Args:
|
|
983
|
+
ctx: MCP context
|
|
984
|
+
cluster_name: Name of the EKS cluster
|
|
985
|
+
kind: Kind of the involved object
|
|
986
|
+
name: Name of the involved object
|
|
987
|
+
namespace: Namespace of the involved object (optional for non-namespaced resources)
|
|
988
|
+
|
|
989
|
+
Returns:
|
|
990
|
+
EventsResponse with events related to the specified object
|
|
991
|
+
"""
|
|
992
|
+
# Check if sensitive data access is disabled
|
|
993
|
+
if not self.allow_sensitive_data_access:
|
|
994
|
+
error_msg = 'Access to Kubernetes events requires --allow-sensitive-data-access flag'
|
|
995
|
+
log_with_request_id(ctx, LogLevel.ERROR, error_msg)
|
|
996
|
+
return EventsResponse(
|
|
997
|
+
isError=True,
|
|
998
|
+
content=[TextContent(type='text', text=error_msg)],
|
|
999
|
+
involved_object_kind=kind,
|
|
1000
|
+
involved_object_name=name,
|
|
1001
|
+
involved_object_namespace=namespace,
|
|
1002
|
+
count=0,
|
|
1003
|
+
events=[],
|
|
1004
|
+
)
|
|
1005
|
+
|
|
1006
|
+
try:
|
|
1007
|
+
# Get Kubernetes client for the cluster
|
|
1008
|
+
k8s_client = self.get_client(cluster_name)
|
|
1009
|
+
|
|
1010
|
+
# Get events
|
|
1011
|
+
events = k8s_client.get_events(
|
|
1012
|
+
kind=kind,
|
|
1013
|
+
name=name,
|
|
1014
|
+
namespace=namespace,
|
|
1015
|
+
)
|
|
1016
|
+
|
|
1017
|
+
# Format resource name for logging
|
|
1018
|
+
resource_name = f'{namespace + "/" if namespace else ""}{name}'
|
|
1019
|
+
|
|
1020
|
+
# Clean up events and create event items
|
|
1021
|
+
cleaned_events = [self.cleanup_resource_response(event) for event in events]
|
|
1022
|
+
event_items = [
|
|
1023
|
+
EventItem(
|
|
1024
|
+
first_timestamp=event['first_timestamp'],
|
|
1025
|
+
last_timestamp=event['last_timestamp'],
|
|
1026
|
+
count=event['count'],
|
|
1027
|
+
message=event['message'],
|
|
1028
|
+
reason=event['reason'],
|
|
1029
|
+
reporting_component=event['reporting_component'],
|
|
1030
|
+
type=event['type'],
|
|
1031
|
+
)
|
|
1032
|
+
for event in cleaned_events
|
|
1033
|
+
]
|
|
1034
|
+
|
|
1035
|
+
log_with_request_id(
|
|
1036
|
+
ctx, LogLevel.INFO, f'Cleaned up events for {kind} {resource_name}'
|
|
1037
|
+
)
|
|
1038
|
+
|
|
1039
|
+
# Log success
|
|
1040
|
+
log_with_request_id(
|
|
1041
|
+
ctx, LogLevel.INFO, f'Retrieved {len(events)} events for {kind} {resource_name}'
|
|
1042
|
+
)
|
|
1043
|
+
|
|
1044
|
+
# Return success response
|
|
1045
|
+
return EventsResponse(
|
|
1046
|
+
isError=False,
|
|
1047
|
+
content=[
|
|
1048
|
+
TextContent(
|
|
1049
|
+
type='text',
|
|
1050
|
+
text=f'Successfully retrieved {len(events)} events for {kind} {resource_name}',
|
|
1051
|
+
)
|
|
1052
|
+
],
|
|
1053
|
+
involved_object_kind=kind,
|
|
1054
|
+
involved_object_name=name,
|
|
1055
|
+
involved_object_namespace=namespace,
|
|
1056
|
+
count=len(events),
|
|
1057
|
+
events=event_items,
|
|
1058
|
+
)
|
|
1059
|
+
|
|
1060
|
+
except Exception as e:
|
|
1061
|
+
# Format resource name for error message
|
|
1062
|
+
resource_name = f'{namespace + "/" if namespace else ""}{name}'
|
|
1063
|
+
|
|
1064
|
+
# Log error
|
|
1065
|
+
error_msg = f'Failed to get events for {kind} {resource_name}: {str(e)}'
|
|
1066
|
+
log_with_request_id(ctx, LogLevel.ERROR, error_msg)
|
|
1067
|
+
|
|
1068
|
+
# Return error response
|
|
1069
|
+
return EventsResponse(
|
|
1070
|
+
isError=True,
|
|
1071
|
+
content=[TextContent(type='text', text=error_msg)],
|
|
1072
|
+
involved_object_kind=kind,
|
|
1073
|
+
involved_object_name=name or '',
|
|
1074
|
+
involved_object_namespace=namespace,
|
|
1075
|
+
count=0,
|
|
1076
|
+
events=[],
|
|
1077
|
+
)
|
|
1078
|
+
|
|
1079
|
+
async def list_api_versions(
|
|
1080
|
+
self,
|
|
1081
|
+
ctx: Context,
|
|
1082
|
+
cluster_name: str = Field(
|
|
1083
|
+
..., description='Name of the EKS cluster to query for available API versions.'
|
|
1084
|
+
),
|
|
1085
|
+
) -> ApiVersionsResponse:
|
|
1086
|
+
"""List all available API versions in the Kubernetes cluster.
|
|
1087
|
+
|
|
1088
|
+
This tool discovers all available API versions on the Kubernetes cluster,
|
|
1089
|
+
which is helpful for determining the correct apiVersion to use when
|
|
1090
|
+
managing Kubernetes resources. It returns both core APIs and API groups,
|
|
1091
|
+
useful for verifying API compatibility and discovering available resources.
|
|
1092
|
+
|
|
1093
|
+
## Response Information
|
|
1094
|
+
The response includes core APIs (like 'v1'), API groups with versions
|
|
1095
|
+
(like 'apps/v1'), extension APIs (like 'networking.k8s.io/v1'), and
|
|
1096
|
+
any Custom Resource Definition (CRD) APIs installed in the cluster.
|
|
1097
|
+
|
|
1098
|
+
## Usage Tips
|
|
1099
|
+
- Use this tool before creating or updating resources to ensure API compatibility
|
|
1100
|
+
- Different Kubernetes versions may have different available APIs
|
|
1101
|
+
- Some APIs may be deprecated or removed in newer Kubernetes versions
|
|
1102
|
+
- Custom resources will only appear if their CRDs are installed in the cluster
|
|
1103
|
+
|
|
1104
|
+
Args:
|
|
1105
|
+
ctx: MCP context
|
|
1106
|
+
cluster_name: Name of the EKS cluster
|
|
1107
|
+
|
|
1108
|
+
Returns:
|
|
1109
|
+
ApiVersionsResponse with list of available API versions
|
|
1110
|
+
"""
|
|
1111
|
+
try:
|
|
1112
|
+
# Get Kubernetes client for the cluster
|
|
1113
|
+
k8s_client = self.get_client(cluster_name)
|
|
1114
|
+
|
|
1115
|
+
# Get API versions from the cluster (excluding core APIs)
|
|
1116
|
+
api_versions = k8s_client.get_api_versions()
|
|
1117
|
+
|
|
1118
|
+
# Log success
|
|
1119
|
+
log_with_request_id(
|
|
1120
|
+
ctx,
|
|
1121
|
+
LogLevel.INFO,
|
|
1122
|
+
f'Retrieved {len(api_versions)} API versions from cluster {cluster_name}',
|
|
1123
|
+
)
|
|
1124
|
+
|
|
1125
|
+
# Return success response
|
|
1126
|
+
return ApiVersionsResponse(
|
|
1127
|
+
isError=False,
|
|
1128
|
+
content=[
|
|
1129
|
+
TextContent(
|
|
1130
|
+
type='text',
|
|
1131
|
+
text=f'Successfully retrieved {len(api_versions)} API versions from cluster {cluster_name}',
|
|
1132
|
+
)
|
|
1133
|
+
],
|
|
1134
|
+
cluster_name=cluster_name,
|
|
1135
|
+
api_versions=api_versions,
|
|
1136
|
+
count=len(api_versions),
|
|
1137
|
+
)
|
|
1138
|
+
|
|
1139
|
+
except Exception as e:
|
|
1140
|
+
# Log error
|
|
1141
|
+
error_msg = f'Failed to get API versions from cluster {cluster_name}: {str(e)}'
|
|
1142
|
+
log_with_request_id(ctx, LogLevel.ERROR, error_msg)
|
|
1143
|
+
|
|
1144
|
+
# Return error response
|
|
1145
|
+
return ApiVersionsResponse(
|
|
1146
|
+
isError=True,
|
|
1147
|
+
content=[TextContent(type='text', text=error_msg)],
|
|
1148
|
+
cluster_name=cluster_name,
|
|
1149
|
+
api_versions=[],
|
|
1150
|
+
count=0,
|
|
1151
|
+
)
|