Skip to content

Commit 516bdc3

Browse files
authored
Fix Missing Kubernetes API Routes for MCP (#351)
* Add Kubernetes API handlers for pods, logs, events, and endpoints - Implemented new handlers in `pkg/fwdapi/handlers/kubernetes.go` to support endpoints for pod logs, pod listing, pod details, events, and service endpoints. - Included parameter validation and error responses for missing or invalid inputs. * Add Kubernetes API handlers for pods, logs, events, and endpoints * Handle service name resolution for missing namespaces and ambiguous matches - Added logic to search for services when `Namespace` is not provided. - Implemented filtering for exact matches and error-handling for ambiguous namespace cases. - Simplified key construction logic for connection info retrieval.
1 parent 60238c7 commit 516bdc3

File tree

3 files changed

+331
-10
lines changed

3 files changed

+331
-10
lines changed

pkg/fwdapi/handlers/kubernetes.go

Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package handlers
22

33
import (
44
"net/http"
5+
"strconv"
56
"time"
67

78
"github.com/gin-gonic/gin"
@@ -202,3 +203,286 @@ func (h *KubernetesHandler) ListContexts(c *gin.Context) {
202203
},
203204
})
204205
}
206+
207+
// GetPodLogs returns logs from a pod
208+
// GET /v1/kubernetes/pods/:namespace/:podName/logs
209+
func (h *KubernetesHandler) GetPodLogs(c *gin.Context) {
210+
if h.discovery == nil {
211+
c.JSON(http.StatusServiceUnavailable, types.Response{
212+
Success: false,
213+
Error: &types.ErrorInfo{
214+
Code: "kubernetes_discovery_unavailable",
215+
Message: "Kubernetes discovery not configured",
216+
},
217+
})
218+
return
219+
}
220+
221+
namespace := c.Param("namespace")
222+
podName := c.Param("podName")
223+
224+
if namespace == "" || podName == "" {
225+
c.JSON(http.StatusBadRequest, types.Response{
226+
Success: false,
227+
Error: &types.ErrorInfo{
228+
Code: "missing_params",
229+
Message: "namespace and podName are required",
230+
},
231+
})
232+
return
233+
}
234+
235+
ctx := c.Query("context")
236+
237+
opts := types.PodLogsOptions{
238+
Container: c.Query("container"),
239+
SinceTime: c.Query("since_time"),
240+
Previous: c.Query("previous") == "true",
241+
Timestamps: c.Query("timestamps") == "true",
242+
}
243+
244+
if tailLines := c.Query("tail_lines"); tailLines != "" {
245+
if n, err := strconv.Atoi(tailLines); err == nil {
246+
opts.TailLines = n
247+
}
248+
}
249+
250+
logs, err := h.discovery.GetPodLogs(ctx, namespace, podName, opts)
251+
if err != nil {
252+
c.JSON(http.StatusInternalServerError, types.Response{
253+
Success: false,
254+
Error: &types.ErrorInfo{
255+
Code: "get_pod_logs_failed",
256+
Message: err.Error(),
257+
},
258+
})
259+
return
260+
}
261+
262+
c.JSON(http.StatusOK, types.Response{
263+
Success: true,
264+
Data: logs,
265+
Meta: &types.MetaInfo{
266+
Timestamp: time.Now(),
267+
},
268+
})
269+
}
270+
271+
// ListPods returns pods in a namespace
272+
// GET /v1/kubernetes/pods/:namespace
273+
func (h *KubernetesHandler) ListPods(c *gin.Context) {
274+
if h.discovery == nil {
275+
c.JSON(http.StatusServiceUnavailable, types.Response{
276+
Success: false,
277+
Error: &types.ErrorInfo{
278+
Code: "kubernetes_discovery_unavailable",
279+
Message: "Kubernetes discovery not configured",
280+
},
281+
})
282+
return
283+
}
284+
285+
namespace := c.Param("namespace")
286+
if namespace == "" {
287+
c.JSON(http.StatusBadRequest, types.Response{
288+
Success: false,
289+
Error: &types.ErrorInfo{
290+
Code: "missing_namespace",
291+
Message: "namespace is required",
292+
},
293+
})
294+
return
295+
}
296+
297+
ctx := c.Query("context")
298+
299+
opts := types.ListPodsOptions{
300+
LabelSelector: c.Query("label_selector"),
301+
FieldSelector: c.Query("field_selector"),
302+
ServiceName: c.Query("service_name"),
303+
}
304+
305+
pods, err := h.discovery.ListPods(ctx, namespace, opts)
306+
if err != nil {
307+
c.JSON(http.StatusInternalServerError, types.Response{
308+
Success: false,
309+
Error: &types.ErrorInfo{
310+
Code: "list_pods_failed",
311+
Message: err.Error(),
312+
},
313+
})
314+
return
315+
}
316+
317+
c.JSON(http.StatusOK, types.Response{
318+
Success: true,
319+
Data: pods,
320+
Meta: &types.MetaInfo{
321+
Count: len(pods),
322+
Timestamp: time.Now(),
323+
},
324+
})
325+
}
326+
327+
// GetPod returns details for a specific pod
328+
// GET /v1/kubernetes/pods/:namespace/:podName
329+
func (h *KubernetesHandler) GetPod(c *gin.Context) {
330+
if h.discovery == nil {
331+
c.JSON(http.StatusServiceUnavailable, types.Response{
332+
Success: false,
333+
Error: &types.ErrorInfo{
334+
Code: "kubernetes_discovery_unavailable",
335+
Message: "Kubernetes discovery not configured",
336+
},
337+
})
338+
return
339+
}
340+
341+
namespace := c.Param("namespace")
342+
podName := c.Param("podName")
343+
344+
if namespace == "" || podName == "" {
345+
c.JSON(http.StatusBadRequest, types.Response{
346+
Success: false,
347+
Error: &types.ErrorInfo{
348+
Code: "missing_params",
349+
Message: "namespace and podName are required",
350+
},
351+
})
352+
return
353+
}
354+
355+
ctx := c.Query("context")
356+
357+
pod, err := h.discovery.GetPod(ctx, namespace, podName)
358+
if err != nil {
359+
c.JSON(http.StatusNotFound, types.Response{
360+
Success: false,
361+
Error: &types.ErrorInfo{
362+
Code: "pod_not_found",
363+
Message: err.Error(),
364+
},
365+
})
366+
return
367+
}
368+
369+
c.JSON(http.StatusOK, types.Response{
370+
Success: true,
371+
Data: pod,
372+
Meta: &types.MetaInfo{
373+
Timestamp: time.Now(),
374+
},
375+
})
376+
}
377+
378+
// GetEvents returns Kubernetes events for a namespace
379+
// GET /v1/kubernetes/events/:namespace
380+
func (h *KubernetesHandler) GetEvents(c *gin.Context) {
381+
if h.discovery == nil {
382+
c.JSON(http.StatusServiceUnavailable, types.Response{
383+
Success: false,
384+
Error: &types.ErrorInfo{
385+
Code: "kubernetes_discovery_unavailable",
386+
Message: "Kubernetes discovery not configured",
387+
},
388+
})
389+
return
390+
}
391+
392+
namespace := c.Param("namespace")
393+
if namespace == "" {
394+
c.JSON(http.StatusBadRequest, types.Response{
395+
Success: false,
396+
Error: &types.ErrorInfo{
397+
Code: "missing_namespace",
398+
Message: "namespace is required",
399+
},
400+
})
401+
return
402+
}
403+
404+
ctx := c.Query("context")
405+
406+
opts := types.GetEventsOptions{
407+
ResourceKind: c.Query("resource_kind"),
408+
ResourceName: c.Query("resource_name"),
409+
}
410+
411+
if limit := c.Query("limit"); limit != "" {
412+
if n, err := strconv.Atoi(limit); err == nil {
413+
opts.Limit = n
414+
}
415+
}
416+
417+
events, err := h.discovery.GetEvents(ctx, namespace, opts)
418+
if err != nil {
419+
c.JSON(http.StatusInternalServerError, types.Response{
420+
Success: false,
421+
Error: &types.ErrorInfo{
422+
Code: "get_events_failed",
423+
Message: err.Error(),
424+
},
425+
})
426+
return
427+
}
428+
429+
c.JSON(http.StatusOK, types.Response{
430+
Success: true,
431+
Data: events,
432+
Meta: &types.MetaInfo{
433+
Count: len(events),
434+
Timestamp: time.Now(),
435+
},
436+
})
437+
}
438+
439+
// GetEndpoints returns endpoints for a service
440+
// GET /v1/kubernetes/endpoints/:namespace/:serviceName
441+
func (h *KubernetesHandler) GetEndpoints(c *gin.Context) {
442+
if h.discovery == nil {
443+
c.JSON(http.StatusServiceUnavailable, types.Response{
444+
Success: false,
445+
Error: &types.ErrorInfo{
446+
Code: "kubernetes_discovery_unavailable",
447+
Message: "Kubernetes discovery not configured",
448+
},
449+
})
450+
return
451+
}
452+
453+
namespace := c.Param("namespace")
454+
serviceName := c.Param("serviceName")
455+
456+
if namespace == "" || serviceName == "" {
457+
c.JSON(http.StatusBadRequest, types.Response{
458+
Success: false,
459+
Error: &types.ErrorInfo{
460+
Code: "missing_params",
461+
Message: "namespace and serviceName are required",
462+
},
463+
})
464+
return
465+
}
466+
467+
ctx := c.Query("context")
468+
469+
endpoints, err := h.discovery.GetEndpoints(ctx, namespace, serviceName)
470+
if err != nil {
471+
c.JSON(http.StatusNotFound, types.Response{
472+
Success: false,
473+
Error: &types.ErrorInfo{
474+
Code: "endpoints_not_found",
475+
Message: err.Error(),
476+
},
477+
})
478+
return
479+
}
480+
481+
c.JSON(http.StatusOK, types.Response{
482+
Success: true,
483+
Data: endpoints,
484+
Meta: &types.MetaInfo{
485+
Timestamp: time.Now(),
486+
},
487+
})
488+
}

pkg/fwdapi/server.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,11 @@ func (m *Manager) setupRouter() *gin.Engine {
127127
v1.GET("/kubernetes/services", k8sHandler.ListServices)
128128
v1.GET("/kubernetes/services/:namespace/:name", k8sHandler.GetService)
129129
v1.GET("/kubernetes/contexts", k8sHandler.ListContexts)
130+
v1.GET("/kubernetes/pods/:namespace", k8sHandler.ListPods)
131+
v1.GET("/kubernetes/pods/:namespace/:podName", k8sHandler.GetPod)
132+
v1.GET("/kubernetes/pods/:namespace/:podName/logs", k8sHandler.GetPodLogs)
133+
v1.GET("/kubernetes/events/:namespace", k8sHandler.GetEvents)
134+
v1.GET("/kubernetes/endpoints/:namespace/:serviceName", k8sHandler.GetEndpoints)
130135
}
131136
}
132137

pkg/fwdmcp/tools.go

Lines changed: 42 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -953,18 +953,50 @@ func (s *Server) handleGetConnectionInfo(ctx context.Context, req *mcp.CallToolR
953953
return nil, nil, fmt.Errorf("service not found: %s", input.ServiceName)
954954
}
955955

956-
// Build key from input: service.namespace.context
957-
key := input.ServiceName
958-
if input.Namespace != "" {
959-
key = input.ServiceName + "." + input.Namespace
960-
// Add context if specified, or use current context
961-
context := input.Context
962-
if context == "" {
963-
context = s.getCurrentContext()
956+
// If namespace is not provided, search for the service
957+
if input.Namespace == "" {
958+
results, err := connInfo.FindServices(input.ServiceName, input.Port, "")
959+
if err != nil {
960+
return nil, nil, fmt.Errorf("failed to search for service: %w", err)
964961
}
965-
if context != "" {
966-
key = key + "." + context
962+
963+
if len(results) == 0 {
964+
return nil, nil, fmt.Errorf("service not found: %s", input.ServiceName)
967965
}
966+
967+
// Filter to exact matches
968+
var exactMatches []types.ConnectionInfoResponse
969+
for _, r := range results {
970+
if r.Service == input.ServiceName {
971+
exactMatches = append(exactMatches, r)
972+
}
973+
}
974+
975+
if len(exactMatches) == 0 {
976+
return nil, nil, fmt.Errorf("service not found: %s", input.ServiceName)
977+
}
978+
979+
if len(exactMatches) == 1 {
980+
return nil, &exactMatches[0], nil
981+
}
982+
983+
// Multiple matches - need namespace to disambiguate
984+
var namespaces []string
985+
for _, r := range exactMatches {
986+
namespaces = append(namespaces, r.Namespace)
987+
}
988+
return nil, nil, fmt.Errorf("multiple services found with name '%s' in namespaces: %v. Please specify namespace", input.ServiceName, namespaces)
989+
}
990+
991+
// Build key from input: service.namespace.context
992+
key := input.ServiceName + "." + input.Namespace
993+
// Add context if specified, or use current context
994+
context := input.Context
995+
if context == "" {
996+
context = s.getCurrentContext()
997+
}
998+
if context != "" {
999+
key = key + "." + context
9681000
}
9691001

9701002
info, err := connInfo.GetConnectionInfo(key)

0 commit comments

Comments
 (0)