Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
name: CI

on:
push:
branches: ['*']
pull_request:
branches: ['*']

jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.23'
- name: Build
run: go build ./...
- name: Vet
run: go vet ./...
- name: Test
run: go test ./... -v -race -count=1
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
.claude/
.DS_Store
.DS_Store
.mcp.json
6 changes: 3 additions & 3 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@ WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /kubelens ./cmd/server
RUN CGO_ENABLED=0 go build -o /k8scope ./cmd/server

FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /kubelens /kubelens
COPY --from=build /k8scope /k8scope
EXPOSE 8080
ENTRYPOINT ["/kubelens"]
ENTRYPOINT ["/k8scope"]
24 changes: 12 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
# KubeLens
# k8scope

A hosted MCP server that lets AI assistants (Claude Code, Cursor, etc.) interact with your GKE clusters using **your own Google identity**. No shared service accounts, no manual token passing — you log in once via browser and the server handles everything.

## How it works

1. You connect Claude Code to the KubeLens server URL
1. You connect Claude Code to the k8scope server URL
2. First time, a browser opens → you log in with Google
3. KubeLens stores your tokens server-side and issues a session ID
3. k8scope stores your tokens server-side and issues a session ID
4. Claude Code sends the session ID on every MCP request
5. KubeLens uses your Google access token to call the GKE API
5. k8scope uses your Google access token to call the GKE API
6. All K8s operations run as **your IAM identity** with your RBAC permissions

## Prerequisites
Expand Down Expand Up @@ -38,7 +38,7 @@ go run ./cmd/server
## Connect from Claude Code

```bash
claude mcp add --transport http kubelens http://localhost:8080/mcp
claude mcp add --transport http k8scope http://localhost:8080/mcp
```

Then use it:
Expand All @@ -53,13 +53,13 @@ Then use it:

```bash
# Build and push
docker build -t gcr.io/YOUR_PROJECT/kubelens .
docker push gcr.io/YOUR_PROJECT/kubelens
docker build -t gcr.io/YOUR_PROJECT/k8scope .
docker push gcr.io/YOUR_PROJECT/k8scope

# Deploy
gcloud run deploy kubelens \
--image gcr.io/YOUR_PROJECT/kubelens \
--set-env-vars "GOOGLE_CLIENT_ID=xxx,GOOGLE_CLIENT_SECRET=xxx,REDIRECT_URL=https://kubelens-xxx.run.app/callback" \
gcloud run deploy k8scope \
--image gcr.io/YOUR_PROJECT/k8scope \
--set-env-vars "GOOGLE_CLIENT_ID=xxx,GOOGLE_CLIENT_SECRET=xxx,REDIRECT_URL=https://k8scope-xxx.run.app/callback" \
--allow-unauthenticated \
--port 8080
```
Expand All @@ -80,7 +80,7 @@ Update the OAuth client's redirect URI to match the Cloud Run URL.
## Architecture

```
Claude Code ──Bearer: session_id──▶ KubeLens MCP Server ──Bearer: ya29.xxx──▶ GKE API Server
Claude Code ──Bearer: session_id──▶ k8scope MCP Server ──Bearer: ya29.xxx──▶ GKE API Server
├── OAuth flow (one-time)
├── Session store (in-memory)
Expand All @@ -90,7 +90,7 @@ Claude Code ──Bearer: session_id──▶ KubeLens MCP Server ──Bearer:
## Project structure

```
kubelens/
k8scope/
├── cmd/server/main.go # Entrypoint, wires OAuth + MCP
├── internal/
│ ├── auth/
Expand Down
10 changes: 5 additions & 5 deletions cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,15 @@ import (

"github.com/mark3labs/mcp-go/server"

"github.com/AdityaK011/kubelens/internal/auth"
"github.com/AdityaK011/kubelens/internal/tools"
"github.com/AdityaK011/k8scope/internal/auth"
"github.com/AdityaK011/k8scope/internal/tools"
)

func main() {
// Required env vars.
clientID := mustEnv("GOOGLE_CLIENT_ID")
clientSecret := mustEnv("GOOGLE_CLIENT_SECRET")
redirectURL := mustEnv("REDIRECT_URL") // e.g. https://kubelens.example.com/callback
redirectURL := mustEnv("REDIRECT_URL") // e.g. https://k8scope.example.com/callback
port := getEnv("PORT", "8080")

// Init OAuth handler.
Expand All @@ -31,7 +31,7 @@ func main() {

// Init MCP server.
mcpServer := server.NewMCPServer(
"kubelens",
"k8scope",
"0.1.0",
server.WithToolCapabilities(true),
)
Expand All @@ -57,7 +57,7 @@ func main() {
mux.Handle("/mcp", oauth.Middleware(mcpHTTP))

addr := fmt.Sprintf(":%s", port)
slog.Info("kubelens MCP server starting",
slog.Info("k8scope MCP server starting",
"port", port,
"redirect_url", redirectURL,
)
Expand Down
5 changes: 3 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
module github.com/AdityaK011/kubelens
module github.com/AdityaK011/k8scope

go 1.25.0

Expand Down Expand Up @@ -39,6 +39,7 @@ require (
github.com/spf13/cast v1.7.1 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
go.opencensus.io v0.24.0 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
golang.org/x/crypto v0.21.0 // indirect
golang.org/x/net v0.22.0 // indirect
golang.org/x/sync v0.20.0 // indirect
Expand All @@ -58,5 +59,5 @@ require (
k8s.io/utils v0.0.0-20230726121419-3b25d923346b // indirect
sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect
sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect
sigs.k8s.io/yaml v1.3.0 // indirect
sigs.k8s.io/yaml v1.6.0 // indirect
)
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,8 @@ github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5t
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI=
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
Expand Down Expand Up @@ -306,3 +308,5 @@ sigs.k8s.io/structured-merge-diff/v4 v4.4.1 h1:150L+0vs/8DA78h1u02ooW1/fFq/Lwr+s
sigs.k8s.io/structured-merge-diff/v4 v4.4.1/go.mod h1:N8hJocpFajUSSeSJ9bOZ77VzejKZaXsTtZo4/u7Io08=
sigs.k8s.io/yaml v1.3.0 h1:a2VclLzOGrwOHDiV8EfBGhvjHvP46CtW5j6POvhYGGo=
sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8=
sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=
167 changes: 167 additions & 0 deletions internal/auth/middleware_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
package auth

import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
)

func TestMiddlewareNoToken(t *testing.T) {
g := newTestGoogleOAuth()

handler := g.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Error("handler should not be called when no token is provided")
}))

req := httptest.NewRequest(http.MethodGet, "/protected", nil)
rec := httptest.NewRecorder()

handler.ServeHTTP(rec, req)

if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusUnauthorized)
}
}

func TestMiddlewareInvalidToken(t *testing.T) {
g := newTestGoogleOAuth()

handler := g.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Error("handler should not be called with invalid token")
}))

req := httptest.NewRequest(http.MethodGet, "/protected", nil)
req.Header.Set("Authorization", "Bearer invalid123")
rec := httptest.NewRecorder()

handler.ServeHTTP(rec, req)

if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusUnauthorized)
}
}

func TestMiddlewareValidToken(t *testing.T) {
g := newTestGoogleOAuth()

// Create a session with an access token that won't expire soon,
// so EnsureFreshToken does not attempt a Google refresh.
sessionID := g.Store.CreateSession(Session{
Email: "[email protected]",
AccessToken: "google-access-token",
RefreshToken: "google-refresh-token",
ExpiresAt: time.Now().Add(time.Hour),
})

var receivedSession *Session
handler := g.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sess, err := SessionFromContext(r.Context())
if err != nil {
t.Errorf("SessionFromContext failed: %v", err)
return
}
receivedSession = sess
w.WriteHeader(http.StatusOK)
}))

req := httptest.NewRequest(http.MethodGet, "/protected", nil)
req.Header.Set("Authorization", "Bearer "+sessionID)
rec := httptest.NewRecorder()

handler.ServeHTTP(rec, req)

if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d; body: %s", rec.Code, http.StatusOK, rec.Body.String())
}

if receivedSession == nil {
t.Fatal("handler did not receive session in context")
}
if receivedSession.Email != "[email protected]" {
t.Errorf("session Email = %q, want %q", receivedSession.Email, "[email protected]")
}
if receivedSession.AccessToken != "google-access-token" {
t.Errorf("session AccessToken = %q, want %q", receivedSession.AccessToken, "google-access-token")
}
}

func TestSessionFromContextWithSession(t *testing.T) {
sess := &Session{
Email: "[email protected]",
AccessToken: "tok-abc",
RefreshToken: "ref-xyz",
ExpiresAt: time.Now().Add(time.Hour),
CreatedAt: time.Now(),
}

ctx := context.WithValue(context.Background(), sessionCtxKey, sess)

got, err := SessionFromContext(ctx)
if err != nil {
t.Fatalf("SessionFromContext failed: %v", err)
}
if got.Email != "[email protected]" {
t.Errorf("Email = %q, want %q", got.Email, "[email protected]")
}
if got.AccessToken != "tok-abc" {
t.Errorf("AccessToken = %q, want %q", got.AccessToken, "tok-abc")
}
}

func TestSessionFromContextWithoutSession(t *testing.T) {
ctx := context.Background()

_, err := SessionFromContext(ctx)
if err == nil {
t.Fatal("expected error from empty context, got nil")
}
}

func TestExtractBearer(t *testing.T) {
tests := []struct {
name string
header string
want string
}{
{
name: "valid bearer token",
header: "Bearer abc123",
want: "abc123",
},
{
name: "basic auth scheme",
header: "Basic xyz",
want: "",
},
{
name: "empty header",
header: "",
want: "",
},
{
name: "bearer with long token",
header: "Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9",
want: "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9",
},
{
name: "lowercase bearer (invalid)",
header: "bearer abc123",
want: "",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
if tt.header != "" {
req.Header.Set("Authorization", tt.header)
}
got := extractBearer(req)
if got != tt.want {
t.Errorf("extractBearer() = %q, want %q", got, tt.want)
}
})
}
}
Loading
Loading