Skip to content

Commit 0e1210d

Browse files
mjudeikis-botmjudeikis-botmjudeikis
authored
fix: filter kubernetes-only edges in MCP handler; add claude setup hints to kedge mcp url (#137)
* fix: filter kubernetes-only edges in MCP handler; add claude setup hints to mcp url - mcp_builder.go: skip server-type edges when resolving MCP targets — server-type edges have no Kubernetes API, only SSH; including them in the MCP provider would fail or return empty toolsets - mcp.go: after printing the MCP URL, print ready-to-use 'claude mcp add' command and Claude Desktop JSON snippet (includes bearer token from the active kubeconfig context when available) Co-authored-by: Mangirdas Judeikis <mangirdas@judeikis.lt> * fix: rename MCP target parameter from 'edge' to 'cluster' 'edge' is kedge-internal terminology. Users think in terms of clusters. Renaming the query parameter to 'cluster' makes the MCP target selection more intuitive for the broader community. Co-authored-by: Mangirdas Judeikis <mangirdas@judeikis.lt> * docs: document MCP integration in README, CONTRIBUTING, and DEVELOPERS - README: add MCP to Features list, Quickstart section with example output, CLI reference entries for kedge mcp url - CONTRIBUTING: add mcp packages to key packages table - DEVELOPERS: add full MCP Integration section covering KubernetesMCP CRD, URL structure, request flow diagram, toolsets, target parameter, and per-edge MCP URL; update table of contents - docs/developers.md: add MCP section for GitHub Pages site Co-authored-by: Mangirdas Judeikis <mangirdas@judeikis.lt> * fix: update e2e tests to use 'cluster' parameter name for MCP tools/call GetTargetParameterName() was renamed from 'edge' to 'cluster' but the e2e test was still passing "edge": edgeName as the tools/call argument, causing 'upstream unavailable' because the MCP server couldn't resolve the target. Co-authored-by: Mangirdas Judeikis <mangirdas@judeikis.lt> --------- Co-authored-by: mjudeikis-bot <ai@faros.sh> Co-authored-by: Mangirdas Judeikis <mangirdas@judeikis.lt>
1 parent 74193d1 commit 0e1210d

9 files changed

Lines changed: 235 additions & 12 deletions

File tree

CONTRIBUTING.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -176,11 +176,13 @@ make test-e2e E2E_FLAGS=--keep-clusters
176176
| Package | Description |
177177
|---------|-------------|
178178
| `pkg/hub/controllers/edge/` | Edge lifecycle: token reconciler, RBAC, SSH credentials |
179-
| `pkg/virtual/builder/` | Agent-proxy virtual workspace — handles tunnel + status |
179+
| `pkg/hub/controllers/mcp/` | KubernetesMCP controller — sets status URL, tracks connected edges |
180+
| `pkg/virtual/builder/` | Agent-proxy + MCP virtual workspaces — handles tunnel, status, MCP handler |
180181
| `pkg/agent/` | Agent core: registration, tunnel, edge_reporter |
181182
| `pkg/agent/tunnel/` | revdial tunnel client (`StartProxyTunnel`) |
182-
| `pkg/cli/cmd/` | CLI command implementations |
183+
| `pkg/cli/cmd/` | CLI command implementations (including `kedge mcp url`) |
183184
| `apis/kedge/v1alpha1/` | Edge CRD types |
185+
| `apis/mcp/v1alpha1/` | KubernetesMCP CRD type (`mcp.kedge.faros.sh`) |
184186

185187
### Join token bootstrap flow
186188

DEVELOPERS.md

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ Deep-dive reference for contributors working on kedge internals.
99
- [Edge Proxy URL Format](#edge-proxy-url-format)
1010
- [SSH Server-Mode Internals](#ssh-server-mode-internals)
1111
- [kcp Workspace Hierarchy](#kcp-workspace-hierarchy)
12+
- [MCP Integration](#mcp-integration)
1213
- [Hub Controller Reference](#hub-controller-reference)
1314

1415
---
@@ -224,6 +225,120 @@ https://kedge.localhost:8443/clusters/<workspace-id>/...
224225
225226
---
226227
228+
## MCP Integration
229+
230+
kedge exposes all connected Kubernetes clusters as a single [Model Context Protocol](https://modelcontextprotocol.io) (MCP) server. AI agents (Claude, Cursor, Copilot, etc.) connect to this endpoint and can interact with all registered clusters using natural language.
231+
232+
### KubernetesMCP CRD
233+
234+
**API group:** `mcp.kedge.faros.sh/v1alpha1`
235+
**Kind:** `KubernetesMCP`
236+
237+
The `KubernetesMCP` object is automatically created as `default` in every tenant workspace by the hub bootstrapper. It acts as the configuration object for the multi-cluster MCP endpoint.
238+
239+
```yaml
240+
apiVersion: mcp.kedge.faros.sh/v1alpha1
241+
kind: KubernetesMCP
242+
metadata:
243+
name: default
244+
spec:
245+
edgeSelector: {} # empty = all kubernetes-type edges; use label selectors to restrict
246+
readOnly: false # set true to disable write operations (create/delete/apply)
247+
status:
248+
URL: "https://hub.example.com/services/mcp/root:kedge:user-<id>/apis/mcp.kedge.faros.sh/v1alpha1/kubernetesmcps/default/mcp"
249+
connectedEdges:
250+
- my-cluster
251+
- home-lab
252+
```
253+
254+
### MCP URL structure
255+
256+
```
257+
https://<hub>/services/mcp/<workspace-cluster-id>/apis/mcp.kedge.faros.sh/v1alpha1/kubernetesmcps/<name>/mcp
258+
```
259+
260+
- `<workspace-cluster-id>` — kcp logical cluster name for the tenant workspace (from the server URL in the user's kubeconfig)
261+
- `<name>` — name of the `KubernetesMCP` object (usually `default`)
262+
263+
### Request flow
264+
265+
```
266+
AI agent (Claude / Cursor / …)
267+
│ POST /services/mcp/<cluster>/…/default/mcp
268+
│ Authorization: Bearer <user-token>
269+
270+
hub MCP virtual workspace handler (pkg/virtual/builder/mcp_builder.go)
271+
272+
│ 1. Extract bearer token, validate against kcp
273+
│ 2. Resolve workspace cluster from URL path
274+
│ 3. Fetch KubernetesMCP object for edgeSelector
275+
│ 4. List all edges in the workspace
276+
│ 5. Filter: kubernetes-type only + connected (tunnel active) + label selector
277+
│ 6. Build MultiEdgeKedgeEdgeProvider (one per request)
278+
279+
280+
kubernetes-mcp-server (github.com/containers/kubernetes-mcp-server)
281+
│ MCP Streamable-HTTP protocol (tools/list, tools/call)
282+
283+
284+
KedgeEdgeProvider / MultiEdgeKedgeEdgeProvider (pkg/virtual/builder/mcp_provider.go)
285+
│ GetTargets() → list of connected cluster names
286+
│ GetDerivedKubernetes(cluster) → rest.Config via revdial tunnel
287+
288+
289+
Agent tunnel → target cluster kube-apiserver
290+
```
291+
292+
### Target parameter
293+
294+
When an MCP client wants to target a specific cluster, it passes `?cluster=<edge-name>` as a query parameter. The parameter name is `cluster` (not `edge`) because users think in terms of clusters.
295+
296+
### Toolsets
297+
298+
The MCP server registers toolsets from `kubernetes-mcp-server` via blank imports in `mcp_builder.go`:
299+
300+
```go
301+
_ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/config"
302+
_ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/core"
303+
_ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/helm"
304+
_ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/kcp"
305+
_ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/kiali"
306+
_ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/kubevirt"
307+
```
308+
309+
Each toolset registers its tools via `init()`. Without the blank imports, `tools/list` returns empty.
310+
311+
### Adding to Claude Code
312+
313+
```bash
314+
kedge mcp url --name default
315+
# prints URL + ready-to-use claude mcp add command with your token
316+
```
317+
318+
Or manually:
319+
320+
```bash
321+
claude mcp add --transport http kedge \
322+
"https://hub.example.com/services/mcp/<cluster-id>/apis/mcp.kedge.faros.sh/v1alpha1/kubernetesmcps/default/mcp" \
323+
-H "Authorization: Bearer <token-from-kubeconfig>"
324+
```
325+
326+
### Per-edge MCP (single cluster)
327+
328+
Each edge also exposes a direct MCP endpoint (independent of `KubernetesMCP`):
329+
330+
```
331+
https://<hub>/services/agent-proxy/<workspace-cluster-id>/apis/kedge.faros.sh/v1alpha1/edges/<name>/mcp
332+
```
333+
334+
```bash
335+
kedge mcp url --edge my-cluster
336+
```
337+
338+
This bypasses `KubernetesMCP` and connects directly to a single edge's Kubernetes API.
339+
340+
---
341+
227342
## Hub Controller Reference
228343

229344
| Controller | Package | Responsibility |

README.md

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ kedge connects your distributed Kubernetes clusters and servers through a single
77
- **Reverse tunnel connectivity** — agents dial out; no inbound firewall rules needed
88
- **Kubernetes edge support** — proxy `kubectl` to any registered cluster via the hub
99
- **SSH server mode** — manage non-Kubernetes hosts (VMs, bare metal) through the same hub
10+
- **MCP integration** — expose all connected clusters as a single [Model Context Protocol](https://modelcontextprotocol.io) server for AI agents (Claude, Cursor, etc.)
1011
- **OIDC authentication** — plug in any OIDC provider (Dex, Auth0, Okta, …)
1112
- **Static token auth** — quick setup for home labs and dev environments
1213
- **Multi-tenant workspaces** — per-user/team kcp workspace isolation
@@ -95,7 +96,36 @@ kedge kubeconfig edge my-cluster > kc.yaml # get a kubeconfig for the edge
9596
kubectl --kubeconfig kc.yaml get nodes
9697
```
9798

98-
### 3. Connect a server (SSH mode)
99+
### 3. Use MCP with AI agents (Claude, Cursor, …)
100+
101+
kedge exposes all your connected Kubernetes clusters as a single MCP server, letting AI coding assistants interact with your clusters directly.
102+
103+
```bash
104+
# Print the MCP endpoint URL + ready-to-use setup commands
105+
kedge mcp url --name default
106+
```
107+
108+
Example output:
109+
```
110+
https://hub.example.com/services/mcp/root:kedge:user-default/apis/mcp.kedge.faros.sh/v1alpha1/kubernetesmcps/default/mcp
111+
112+
To add this MCP server to Claude Code:
113+
claude mcp add --transport http kedge "https://hub.example.com/..." -H "Authorization: Bearer <token>"
114+
115+
To add to Claude Desktop (claude_desktop_config.json):
116+
{
117+
"mcpServers": {
118+
"kedge": {
119+
"url": "https://hub.example.com/...",
120+
"headers": { "Authorization": "Bearer <token>" }
121+
}
122+
}
123+
}
124+
```
125+
126+
The MCP server aggregates **all connected kubernetes-type clusters** into one endpoint. AI agents can list pods, describe deployments, apply manifests, and more — across all your clusters at once.
127+
128+
### 4. Connect a server (SSH mode)
99129

100130
```bash
101131
kedge edge create my-server --type server
@@ -124,6 +154,8 @@ kedge ssh my-server -- df -h # single command
124154
| `kedge ssh <name> -- <cmd>` | Run a single command on a server-mode edge |
125155
| `kedge agent run` | Start the agent as a foreground process |
126156
| `kedge agent join` | Install the agent as a persistent service (systemd / Deployment) |
157+
| `kedge mcp url --name <name>` | Print the KubernetesMCP multi-cluster MCP endpoint URL |
158+
| `kedge mcp url --edge <name>` | Print the per-edge MCP endpoint URL |
127159

128160
## Documentation
129161

docs/developers.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,3 +305,36 @@ kedge dev create --chart-path deploy/charts/kedge-hub
305305
```
306306

307307
The agent establishes a reverse WebSocket tunnel to the hub, allowing the hub to proxy API requests to the agent's cluster.
308+
309+
---
310+
311+
## MCP Integration
312+
313+
kedge exposes all connected Kubernetes clusters as a single [Model Context Protocol](https://modelcontextprotocol.io) (MCP) server.
314+
315+
### URL format
316+
317+
```
318+
https://<hub>/services/mcp/<workspace-cluster-id>/apis/mcp.kedge.faros.sh/v1alpha1/kubernetesmcps/<name>/mcp
319+
```
320+
321+
### Getting the URL
322+
323+
```bash
324+
kedge mcp url --name default
325+
```
326+
327+
This prints the URL and a ready-to-use `claude mcp add` command with your bearer token.
328+
329+
### KubernetesMCP resource
330+
331+
A `default` `KubernetesMCP` object is auto-created in every tenant workspace. It selects which kubernetes-type edges are included via `spec.edgeSelector` (empty = all connected kubernetes edges).
332+
333+
### How it works
334+
335+
1. The hub's MCP virtual workspace handler validates the bearer token.
336+
2. Lists all `Edge` objects in the workspace, filters to `spec.type: kubernetes` + connected + label selector.
337+
3. Builds a `MultiEdgeKedgeEdgeProvider` that dials each edge over its revdial tunnel.
338+
4. Passes control to `kubernetes-mcp-server` which implements the MCP protocol.
339+
340+
See [DEVELOPERS.md](https://github.com/faroshq/kedge/blob/main/DEVELOPERS.md#mcp-integration) for the full internals reference.

pkg/cli/cmd/mcp.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,36 @@ func runMCPURL(_ *cobra.Command, edgeName, kubernetesName string) error {
124124
}
125125

126126
fmt.Println(mcpURL)
127+
128+
// Resolve the bearer token from the kubeconfig for the usage hint.
129+
token := ""
130+
if currentCtx, ok := rawCfg.Contexts[rawCfg.CurrentContext]; ok {
131+
if u, ok := rawCfg.AuthInfos[currentCtx.AuthInfo]; ok {
132+
token = u.Token
133+
}
134+
}
135+
136+
fmt.Println()
137+
fmt.Println("To add this MCP server to Claude Code:")
138+
if token != "" {
139+
fmt.Printf(" claude mcp add --transport http kedge \"%s\" -H \"Authorization: Bearer %s\"\n", mcpURL, token)
140+
} else {
141+
fmt.Printf(" claude mcp add --transport http kedge \"%s\" -H \"Authorization: Bearer <your-token>\"\n", mcpURL)
142+
}
143+
fmt.Println()
144+
fmt.Println("To add to Claude Desktop (claude_desktop_config.json):")
145+
fmt.Println(" {")
146+
fmt.Println(" \"mcpServers\": {")
147+
fmt.Println(" \"kedge\": {")
148+
fmt.Printf(" \"url\": \"%s\",\n", mcpURL)
149+
if token != "" {
150+
fmt.Printf(" \"headers\": { \"Authorization\": \"Bearer %s\" }\n", token)
151+
} else {
152+
fmt.Println(" \"headers\": { \"Authorization\": \"Bearer <your-token>\" }")
153+
}
154+
fmt.Println(" }")
155+
fmt.Println(" }")
156+
fmt.Println(" }")
127157
return nil
128158
}
129159

pkg/virtual/builder/mcp_builder.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import (
3232
_ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/kiali"
3333
_ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/kubevirt"
3434
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
35+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
3536
"k8s.io/apimachinery/pkg/labels"
3637
"k8s.io/apimachinery/pkg/runtime/schema"
3738
"k8s.io/client-go/dynamic"
@@ -199,6 +200,14 @@ func (p *virtualWorkspaces) buildKubernetesMCPHandler() http.Handler {
199200
for _, edgeObj := range edgeList.Items {
200201
edgeName := edgeObj.GetName()
201202
edgeLabels := edgeObj.GetLabels()
203+
204+
// Only include kubernetes-type edges — server-type edges provide
205+
// SSH access only and have no Kubernetes API to proxy via MCP.
206+
edgeType, _, _ := unstructured.NestedString(edgeObj.Object, "spec", "type")
207+
if edgeType != "kubernetes" {
208+
continue
209+
}
210+
202211
if !edgeSelector.Matches(labels.Set(edgeLabels)) {
203212
continue
204213
}

pkg/virtual/builder/mcp_provider.go

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -103,8 +103,10 @@ func (p *KedgeEdgeProvider) GetDerivedKubernetes(_ context.Context, edgeName str
103103
// GetDefaultTarget returns an empty string — there is no single default edge.
104104
func (p *KedgeEdgeProvider) GetDefaultTarget() string { return "" }
105105

106-
// GetTargetParameterName returns "edge" as the MCP target query parameter.
107-
func (p *KedgeEdgeProvider) GetTargetParameterName() string { return "edge" }
106+
// GetTargetParameterName returns "cluster" as the MCP target query parameter.
107+
// "cluster" is used rather than "edge" because users think in terms of clusters,
108+
// not kedge-internal "edge" terminology.
109+
func (p *KedgeEdgeProvider) GetTargetParameterName() string { return "cluster" }
108110

109111
// WatchTargets is a no-op for v1; dynamic target reloading is not implemented.
110112
func (p *KedgeEdgeProvider) WatchTargets(_ mcpkubernetes.McpReload) {}
@@ -160,8 +162,8 @@ func (p *MultiEdgeKedgeEdgeProvider) GetDerivedKubernetes(ctx context.Context, e
160162
// GetDefaultTarget returns an empty string (no single default edge).
161163
func (p *MultiEdgeKedgeEdgeProvider) GetDefaultTarget() string { return "" }
162164

163-
// GetTargetParameterName returns "edge".
164-
func (p *MultiEdgeKedgeEdgeProvider) GetTargetParameterName() string { return "edge" }
165+
// GetTargetParameterName returns "cluster".
166+
func (p *MultiEdgeKedgeEdgeProvider) GetTargetParameterName() string { return "cluster" }
165167

166168
// WatchTargets is a no-op.
167169
func (p *MultiEdgeKedgeEdgeProvider) WatchTargets(_ mcpkubernetes.McpReload) {}

pkg/virtual/builder/mcp_provider_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -156,10 +156,10 @@ func TestGetDefaultTarget(t *testing.T) {
156156
}
157157
}
158158

159-
// TestGetTargetParameterName asserts that GetTargetParameterName returns "edge".
159+
// TestGetTargetParameterName asserts that GetTargetParameterName returns "cluster".
160160
func TestGetTargetParameterName(t *testing.T) {
161161
provider := &KedgeEdgeProvider{}
162-
if got := provider.GetTargetParameterName(); got != "edge" {
163-
t.Errorf("GetTargetParameterName() = %q; want %q", got, "edge")
162+
if got := provider.GetTargetParameterName(); got != "cluster" {
163+
t.Errorf("GetTargetParameterName() = %q; want %q", got, "cluster")
164164
}
165165
}

test/e2e/cases/mcp.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -254,7 +254,7 @@ func MCPEndpoint() features.Feature {
254254
}
255255

256256
result, err := mcp.toolsCall(ctx, "namespaces_list", map[string]any{
257-
"edge": edgeName,
257+
"cluster": edgeName,
258258
})
259259
if err != nil {
260260
t.Fatalf("tools/call namespaces_list failed: %v", err)
@@ -274,7 +274,7 @@ func MCPEndpoint() features.Feature {
274274

275275
result, err := mcp.toolsCall(ctx, "pods_list_in_namespace", map[string]any{
276276
"namespace": "kube-system",
277-
"edge": edgeName,
277+
"cluster": edgeName,
278278
})
279279
if err != nil {
280280
t.Fatalf("tools/call pods_list_in_namespace failed: %v", err)

0 commit comments

Comments
 (0)