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.
@@ -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
+ )