diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000..0a0562142 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,32 @@ +name: release + +on: + workflow_dispatch: + +permissions: + contents: write + +jobs: + create_release: + runs-on: ubuntu-latest + steps: + - name: checkout + uses: actions/checkout@v3 + - name: Execute shell script + id: build_deploy + run: | + chmod +x ./build-deploy.sh + ./build-deploy.sh + release_name=$(find . -name "zoekt-*.gz" | xargs -I'{}' basename "{}" | sed 's/zoekt-//' | cut -d. -f1) + echo "release_name=$release_name" >> "$GITHUB_OUTPUT" + - name: Create Release + id: create_release + uses: softprops/action-gh-release@v2 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + repository: leboncoin/zoekt + tag_name: ${{ steps.build_deploy.outputs.release_name }} + files: zoekt-${{ steps.build_deploy.outputs.release_name }}.tar.gz + draft: false + prerelease: false diff --git a/.gitignore b/.gitignore index 054270c28..d52a8ef56 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ bazel-bin bazel-out bazel-testlogs bazel-zoekt +zoekt-bin diff --git a/build-deploy.sh b/build-deploy.sh new file mode 100755 index 000000000..e4a41be99 --- /dev/null +++ b/build-deploy.sh @@ -0,0 +1,55 @@ +#!/bin/bash + +# this script packages up all the binaries, and a script (deploy.sh) +# to twiddle with the server and the binaries + +set -ex + +# Put the date first so we can sort. +if [[ -z "$VERSION" ]]; then + VERSION=$(date --iso-8601=minutes | tr -d ':' | sed 's|\+.*$||') + if [[ -d .git ]]; then + VERSION=${VERSION}-$(git show --pretty=format:%h -q) + fi +fi + +set -u + +out=zoekt-${VERSION} +mkdir -p ${out} + +for d in $(find cmd/ -maxdepth 1 -type d) +do + go build \ + -tags netgo \ + -ldflags "-X github.com/sourcegraph/zoekt.Version=dev" \ + -o ${out}/$(basename $d) \ + github.com/sourcegraph/zoekt/$d +done + +cat < ${out}/deploy.sh +#!/bin/bash + +echo "Set the following in the environment." +echo "" +echo ' export PATH="'$PWD'/bin:$PATH' +echo "" + +set -eux + +# Allow sandbox to create NS's +sudo sh -c 'echo 1 > /proc/sys/kernel/unprivileged_userns_clone' + +# we mmap the entire index, but typically only want the file contents. +sudo sh -c 'echo 1 >/proc/sys/vm/overcommit_memory' + +# allow bind to 80 and 443 +sudo setcap 'cap_net_bind_service=+ep' bin/zoekt-webserver + +EOF + +chmod 755 ${out}/* + +tar --owner=root --group=root -czf zoekt-deploy-${VERSION}.tar.gz ${out}/* + +rm -rf ${out} diff --git a/build.sh b/build.sh new file mode 100755 index 000000000..ef25dccc4 --- /dev/null +++ b/build.sh @@ -0,0 +1,22 @@ +#!/bin/bash + +# this script packages up all the binaries, and a script (deploy.sh) +# to twiddle with the server and the binaries + +set -ex + +set -u + +out=zoekt-bin +mkdir -p ${out} + +for d in $(find cmd/ -maxdepth 1 -type d) +do + go build \ + -tags netgo \ + -ldflags "-X github.com/sourcegraph/zoekt.Version=dev" \ + -o ${out}/$(basename $d) \ + github.com/sourcegraph/zoekt/$d +done + +chmod 755 ${out}/* diff --git a/cmd/zoekt-webserver/main.go b/cmd/zoekt-webserver/main.go index d9e5c5773..c25e10489 100644 --- a/cmd/zoekt-webserver/main.go +++ b/cmd/zoekt-webserver/main.go @@ -252,6 +252,8 @@ func main() { debugserver.AddHandlers(serveMux, *enablePprof) + addMCPHandlers(serveMux, searcher) + if *enableIndexserverProxy { socket := filepath.Join(*indexDir, "indexserver.sock") sglog.Scoped("server").Info("adding reverse proxy", sglog.String("socket", socket)) diff --git a/cmd/zoekt-webserver/mcp.go b/cmd/zoekt-webserver/mcp.go new file mode 100644 index 000000000..32ac24708 --- /dev/null +++ b/cmd/zoekt-webserver/mcp.go @@ -0,0 +1,288 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "os" + "strings" + "time" + + "github.com/lestrrat-go/jwx/v2/jwk" + "github.com/lestrrat-go/jwx/v2/jwt" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" + sglog "github.com/sourcegraph/log" + + "github.com/sourcegraph/zoekt" + "github.com/sourcegraph/zoekt/index" + "github.com/sourcegraph/zoekt/query" +) + +const ( + mcpPath = "/mcp" + wellKnownPath = "/.well-known/oauth-authorization-server" +) + +type mcpContextKey string + +const tokenSubjectKey mcpContextKey = "token_subject" + +var proxyClient = &http.Client{Timeout: 5 * time.Second} + +func subjectFromContext(ctx context.Context) (string, bool) { + sub, ok := ctx.Value(tokenSubjectKey).(string) + return sub, ok && sub != "" +} + +// addMCPHandlers registers the MCP server and OAuth discovery routes on mux. +func addMCPHandlers(mux *http.ServeMux, searcher zoekt.Streamer) { + logger := sglog.Scoped("mcp") + + oktaBaseURL := os.Getenv("ZOEKT_OKTA_BASE_URL") + if oktaBaseURL == "" { + logger.Warn("ZOEKT_OKTA_BASE_URL not set, MCP routes will return 503") + unavailable := "MCP not configured: ZOEKT_OKTA_BASE_URL not set" + mux.HandleFunc(mcpPath, func(w http.ResponseWriter, r *http.Request) { + http.Error(w, unavailable, http.StatusServiceUnavailable) + }) + mux.HandleFunc(wellKnownPath, func(w http.ResponseWriter, r *http.Request) { + http.Error(w, unavailable, http.StatusServiceUnavailable) + }) + return + } + + verifier, err := newJWTVerifier(context.Background(), oktaBaseURL, logger) + if err != nil { + logger.Error("failed to initialize JWT verifier", sglog.Error(err)) + errMsg := fmt.Sprintf("MCP unavailable: JWT verifier init failed: %v", err) + mux.HandleFunc(mcpPath, func(w http.ResponseWriter, r *http.Request) { + http.Error(w, errMsg, http.StatusServiceUnavailable) + }) + mux.HandleFunc(wellKnownPath, makeWellKnownHandler(oktaBaseURL, logger)) + return + } + + mcpServer := buildMCPServer(searcher, logger) + httpServer := server.NewStreamableHTTPServer(mcpServer) + + mux.Handle(mcpPath, jwtAuthMiddleware(verifier, logger, httpServer)) + mux.HandleFunc(wellKnownPath, makeWellKnownHandler(oktaBaseURL, logger)) +} + +// tokenVerifier is an interface for JWT verification, allowing test doubles. +type tokenVerifier interface { + verify(authHeader string) (string, error) +} + +// jwtAuthMiddleware validates the Bearer token and injects the subject into the request context. +func jwtAuthMiddleware(v tokenVerifier, logger sglog.Logger, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sub, err := v.verify(r.Header.Get("Authorization")) + if err != nil { + if isJWKSError(err) { + logger.Error("JWKS verification infrastructure failure", + sglog.Error(err), + sglog.String("remote_addr", r.RemoteAddr), + ) + } + w.Header().Set("Content-Type", "application/json") + w.Header().Set("WWW-Authenticate", `Bearer error="invalid_token"`) + w.WriteHeader(http.StatusUnauthorized) + if encErr := json.NewEncoder(w).Encode(map[string]string{ + "error": "invalid_token", + "error_description": "Authentication required", + }); encErr != nil { + logger.Warn("failed to write 401 response body", sglog.Error(encErr)) + } + return + } + next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), tokenSubjectKey, sub))) + }) +} + +// isJWKSError distinguishes infrastructure failures (JWKS fetch) from routine token +// validation failures (bad token, expired, wrong issuer), so callers can log +// infrastructure failures without spamming logs for every bad client request. +func isJWKSError(err error) bool { + return strings.Contains(err.Error(), "failed to fetch JWKS") +} + +// makeWellKnownHandler proxies Okta's OAuth metadata so Claude Code (or MCP client) can discover +// /authorize and /token to authenticate. No Dynamic Client Registration (DCR) : a client_id is provided. +func makeWellKnownHandler(oktaBaseURL string, logger sglog.Logger) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + resp, err := proxyClient.Get(oktaBaseURL + "/.well-known/oauth-authorization-server") + if err != nil { + logger.Error("failed to fetch Okta metadata", sglog.Error(err)) + http.Error(w, fmt.Sprintf("failed to reach Okta: %v", err), http.StatusBadGateway) + return + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + logger.Error("Okta metadata returned non-200", sglog.Int("status", resp.StatusCode)) + http.Error(w, fmt.Sprintf("Okta returned %d", resp.StatusCode), http.StatusBadGateway) + return + } + + var metadata map[string]any + if err := json.NewDecoder(resp.Body).Decode(&metadata); err != nil { + logger.Error("failed to decode Okta metadata", sglog.Error(err)) + http.Error(w, "upstream error", http.StatusBadGateway) + return + } + + w.Header().Set("Content-Type", "application/json") + if encErr := json.NewEncoder(w).Encode(metadata); encErr != nil { + logger.Warn("failed to write well-known response body", sglog.Error(encErr)) + } + } +} + +// jwtVerifier validates Okta JWT tokens via JWKS. +type jwtVerifier struct { + jwksURL string + issuer string + cache *jwk.Cache +} + +func newJWTVerifier(ctx context.Context, oktaBaseURL string, logger sglog.Logger) (*jwtVerifier, error) { + jwksURL := oktaBaseURL + "/oauth2/v1/keys" + + cache := jwk.NewCache(ctx) + if err := cache.Register(jwksURL, jwk.WithMinRefreshInterval(15*time.Minute)); err != nil { + return nil, fmt.Errorf("failed to register JWKS URL: %w", err) + } + go func() { + if _, err := cache.Refresh(ctx, jwksURL); err != nil { + logger.Error("initial JWKS fetch failed; authentication unavailable until cache refreshes", + sglog.String("url", jwksURL), + sglog.Error(err), + ) + } + }() + + return &jwtVerifier{ + jwksURL: jwksURL, + issuer: oktaBaseURL, + cache: cache, + }, nil +} + +// verify extracts and validates the Bearer token, returning the subject claim. +func (v *jwtVerifier) verify(authHeader string) (string, error) { + if !strings.HasPrefix(authHeader, "Bearer ") { + return "", fmt.Errorf("missing Bearer token") + } + tokenStr := strings.TrimPrefix(authHeader, "Bearer ") + + // CachedSet resolves keys from RAM; triggers a JWKS refresh only on unknown kid (okta key rotation). + tok, err := jwt.Parse([]byte(tokenStr), + jwt.WithKeySet(jwk.NewCachedSet(v.cache, v.jwksURL)), + jwt.WithIssuer(v.issuer), + jwt.WithValidate(true), + ) + if err != nil { + return "", fmt.Errorf("invalid token: %w", err) + } + + return tok.Subject(), nil +} + +// buildMCPServer creates the MCP server with the zoekt_search tool. +func buildMCPServer(searcher zoekt.Streamer, logger sglog.Logger) *server.MCPServer { + s := server.NewMCPServer("zoekt-search", index.Version, + server.WithToolCapabilities(false), + ) + + zoektSearchTool := mcp.NewTool("zoekt_search", + mcp.WithDescription("Search code across all internal LBC repositories using Zoekt."), + mcp.WithString("query", + mcp.Required(), + mcp.Description(`Zoekt query string. Examples: + "needle" full-text search + "r:reponame" repo filter + "file:template" filename filter + "lang:yaml" language filter + "sym:data" symbol definitions + "fork:no" exclude forks + "-lang:go" negation (exclude) + "foo or bar" logical OR (AND is implicit)`), + ), + mcp.WithNumber("num", + mcp.Description("Max number of results (default 200)"), + ), + ) + + s.AddTool(zoektSearchTool, func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + queryStr, err := req.RequireString("query") + if err != nil { + return mcp.NewToolResultError("query parameter is required"), nil + } + + num := 200 + if n := req.GetFloat("num", 0); n > 0 { + num = int(n) //nolint:gosec + } + + sub, ok := subjectFromContext(ctx) + if !ok { + logger.Error("zoekt_search reached tool handler without authenticated subject") + return mcp.NewToolResultError("internal error: unauthenticated request"), nil + } + logger.Info("zoekt_search called", + sglog.String("subject", sub), + sglog.String("query", queryStr), + sglog.Int("num", num), + ) + + results, err := runSearch(ctx, searcher, queryStr, num) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("search failed: %v", err)), nil + } + + out, err := json.Marshal(results) + if err != nil { + logger.Error("failed to marshal search results", + sglog.Error(err), + sglog.String("query", queryStr), + ) + return mcp.NewToolResultError("internal error: failed to serialize results"), nil + } + return mcp.NewToolResultText(string(out)), nil + }) + + return s +} + +type searchResult struct { + FileCountTotal int `json:"file_count_total"` + MatchCountTotal int `json:"match_count_total"` + Truncated bool `json:"truncated"` + Files []zoekt.FileMatch `json:"files"` +} + +func runSearch(ctx context.Context, searcher zoekt.Streamer, queryStr string, num int) (*searchResult, error) { + q, err := query.Parse(queryStr) + if err != nil { + return nil, fmt.Errorf("invalid query: %w", err) + } + + opts := &zoekt.SearchOptions{ + MaxDocDisplayCount: num, + } + + sr, err := searcher.Search(ctx, q, opts) + if err != nil { + return nil, err + } + + return &searchResult{ + FileCountTotal: sr.Stats.FileCount, + MatchCountTotal: sr.Stats.MatchCount, + Truncated: len(sr.Files) < sr.Stats.FileCount, + Files: sr.Files, + }, nil +} diff --git a/cmd/zoekt-webserver/mcp_test.go b/cmd/zoekt-webserver/mcp_test.go new file mode 100644 index 000000000..1c333565a --- /dev/null +++ b/cmd/zoekt-webserver/mcp_test.go @@ -0,0 +1,322 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + sglog "github.com/sourcegraph/log" + "github.com/sourcegraph/log/logtest" + "github.com/sourcegraph/zoekt" + "github.com/sourcegraph/zoekt/internal/mockSearcher" + "github.com/sourcegraph/zoekt/query" +) + +// --- jwtAuthMiddleware --- + +func TestJWTAuthMiddleware_NoToken(t *testing.T) { + v := &fakeVerifier{err: errors.New("missing Bearer token")} + handler := jwtAuthMiddleware(v, noopLogger(t), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + req := httptest.NewRequest(http.MethodGet, "/mcp", nil) + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", rr.Code) + } + if rr.Header().Get("WWW-Authenticate") != `Bearer error="invalid_token"` { + t.Fatalf("unexpected WWW-Authenticate: %s", rr.Header().Get("WWW-Authenticate")) + } + var body map[string]string + if err := json.NewDecoder(rr.Body).Decode(&body); err != nil { + t.Fatalf("decode body: %v", err) + } + if body["error"] != "invalid_token" { + t.Fatalf("unexpected error field: %s", body["error"]) + } + if body["error_description"] == "" { + t.Fatal("expected non-empty error_description") + } +} + +func TestJWTAuthMiddleware_ValidToken(t *testing.T) { + v := &fakeVerifier{subject: "user@example.com"} + + var capturedSub string + handler := jwtAuthMiddleware(v, noopLogger(t), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedSub, _ = subjectFromContext(r.Context()) + w.WriteHeader(http.StatusOK) + })) + + req := httptest.NewRequest(http.MethodGet, "/mcp", nil) + req.Header.Set("Authorization", "Bearer sometoken") + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rr.Code) + } + if capturedSub != "user@example.com" { + t.Fatalf("expected subject in context, got %q", capturedSub) + } +} + +func TestJWTAuthMiddleware_JWKSError_Returns401(t *testing.T) { + v := &fakeVerifier{err: errors.New("failed to fetch JWKS: connection refused")} + handler := jwtAuthMiddleware(v, noopLogger(t), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + req := httptest.NewRequest(http.MethodGet, "/mcp", nil) + req.Header.Set("Authorization", "Bearer sometoken") + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", rr.Code) + } +} + +// --- subjectFromContext --- + +func TestSubjectFromContext_Missing(t *testing.T) { + _, ok := subjectFromContext(context.Background()) + if ok { + t.Fatal("expected ok=false for empty context") + } +} + +func TestSubjectFromContext_EmptyString(t *testing.T) { + ctx := context.WithValue(context.Background(), tokenSubjectKey, "") + _, ok := subjectFromContext(ctx) + if ok { + t.Fatal("expected ok=false for empty subject string") + } +} + +func TestSubjectFromContext_Present(t *testing.T) { + ctx := context.WithValue(context.Background(), tokenSubjectKey, "user@example.com") + sub, ok := subjectFromContext(ctx) + if !ok { + t.Fatal("expected ok=true") + } + if sub != "user@example.com" { + t.Fatalf("unexpected subject: %s", sub) + } +} + +// --- makeWellKnownHandler --- + +func TestMakeWellKnownHandler_Success(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "issuer": "https://example.okta.com", + "authorization_endpoint": "https://example.okta.com/oauth2/v1/authorize", + }) + })) + defer upstream.Close() + + handler := makeWellKnownHandler(upstream.URL, noopLogger(t)) + req := httptest.NewRequest(http.MethodGet, "/.well-known/oauth-authorization-server", nil) + rr := httptest.NewRecorder() + handler(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rr.Code) + } + var metadata map[string]any + if err := json.NewDecoder(rr.Body).Decode(&metadata); err != nil { + t.Fatalf("decode response: %v", err) + } + if metadata["issuer"] != "https://example.okta.com" { + t.Fatalf("unexpected issuer: %v", metadata["issuer"]) + } +} + +func TestMakeWellKnownHandler_UpstreamError(t *testing.T) { + handler := makeWellKnownHandler("http://127.0.0.1:0", noopLogger(t)) + req := httptest.NewRequest(http.MethodGet, "/.well-known/oauth-authorization-server", nil) + rr := httptest.NewRecorder() + handler(rr, req) + + if rr.Code != http.StatusBadGateway { + t.Fatalf("expected 502, got %d", rr.Code) + } +} + +func TestMakeWellKnownHandler_UpstreamNon200(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer upstream.Close() + + handler := makeWellKnownHandler(upstream.URL, noopLogger(t)) + req := httptest.NewRequest(http.MethodGet, "/.well-known/oauth-authorization-server", nil) + rr := httptest.NewRecorder() + handler(rr, req) + + if rr.Code != http.StatusBadGateway { + t.Fatalf("expected 502 for upstream 500, got %d", rr.Code) + } +} + +func TestMakeWellKnownHandler_UpstreamInvalidJSON(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("not-json")) + })) + defer upstream.Close() + + handler := makeWellKnownHandler(upstream.URL, noopLogger(t)) + req := httptest.NewRequest(http.MethodGet, "/.well-known/oauth-authorization-server", nil) + rr := httptest.NewRecorder() + handler(rr, req) + + if rr.Code != http.StatusBadGateway { + t.Fatalf("expected 502 for invalid JSON, got %d", rr.Code) + } +} + +// --- runSearch --- + +func TestRunSearch_ValidQuery(t *testing.T) { + mock := &mockSearcher.MockSearcher{ + WantSearch: mustParseQuery(t, "hello"), + SearchResult: &zoekt.SearchResult{ + Files: []zoekt.FileMatch{{FileName: "foo.go"}}, + Stats: zoekt.Stats{FileCount: 1, MatchCount: 1}, + }, + } + + result, err := runSearch(context.Background(), streamAdapter{mock}, "hello", 10) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.FileCountTotal != 1 { + t.Fatalf("expected 1 file, got %d", result.FileCountTotal) + } + if result.Truncated { + t.Fatal("expected not truncated") + } +} + +func TestRunSearch_InvalidQuery(t *testing.T) { + _, err := runSearch(context.Background(), streamAdapter{&mockSearcher.MockSearcher{}}, "(", 10) + if err == nil { + t.Fatal("expected error for invalid query") + } +} + +func TestRunSearch_SearcherError(t *testing.T) { + _, err := runSearch(context.Background(), &errorSearcher{err: errors.New("index unavailable")}, "hello", 10) + if err == nil { + t.Fatal("expected error to be propagated from searcher") + } +} + +func TestRunSearch_Truncated(t *testing.T) { + mock := &mockSearcher.MockSearcher{ + WantSearch: mustParseQuery(t, "hello"), + SearchResult: &zoekt.SearchResult{ + Files: []zoekt.FileMatch{{FileName: "foo.go"}}, + Stats: zoekt.Stats{FileCount: 5, MatchCount: 5}, + }, + } + + result, err := runSearch(context.Background(), streamAdapter{mock}, "hello", 1) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !result.Truncated { + t.Fatal("expected truncated=true when returned files < total file count") + } +} + +// --- addMCPHandlers --- + +func TestAddMCPHandlers_SkipsWhenEnvUnset(t *testing.T) { + t.Setenv("ZOEKT_OKTA_BASE_URL", "") + mux := http.NewServeMux() + addMCPHandlers(mux, streamAdapter{&mockSearcher.MockSearcher{}}) + + for _, path := range []string{mcpPath, wellKnownPath} { + req := httptest.NewRequest(http.MethodGet, path, nil) + rr := httptest.NewRecorder() + mux.ServeHTTP(rr, req) + if rr.Code != http.StatusServiceUnavailable { + t.Fatalf("expected route %s to return 503, got %d", path, rr.Code) + } + if !strings.Contains(rr.Body.String(), "ZOEKT_OKTA_BASE_URL not set") { + t.Fatalf("expected body to mention ZOEKT_OKTA_BASE_URL, got %q", rr.Body.String()) + } + } +} + +// --- helpers --- + +// fakeVerifier is a test double for tokenVerifier. +type fakeVerifier struct { + subject string + err error +} + +func (f *fakeVerifier) verify(_ string) (string, error) { + return f.subject, f.err +} + +// streamAdapter wraps a Searcher to satisfy the Streamer interface. +type streamAdapter struct { + zoekt.Searcher +} + +func (a streamAdapter) StreamSearch(ctx context.Context, q query.Q, opts *zoekt.SearchOptions, sender zoekt.Sender) error { + sr, err := a.Searcher.Search(ctx, q, opts) + if err != nil { + return err + } + sender.Send(sr) + return nil +} + +// errorSearcher always returns an error from Search. +type errorSearcher struct { + err error +} + +func (e *errorSearcher) Search(_ context.Context, _ query.Q, _ *zoekt.SearchOptions) (*zoekt.SearchResult, error) { + return nil, e.err +} + +func (e *errorSearcher) StreamSearch(_ context.Context, _ query.Q, _ *zoekt.SearchOptions, _ zoekt.Sender) error { + return e.err +} + +func (*errorSearcher) List(_ context.Context, _ query.Q, _ *zoekt.ListOptions) (*zoekt.RepoList, error) { + return nil, nil +} + +func (*errorSearcher) Close() {} + +func (*errorSearcher) String() string { return "errorSearcher" } + +func noopLogger(t *testing.T) sglog.Logger { + t.Helper() + logger, _ := logtest.Captured(t) + return logger +} + +func mustParseQuery(t *testing.T, s string) query.Q { + t.Helper() + q, err := query.Parse(s) + if err != nil { + t.Fatalf("parse query %q: %v", s, err) + } + return q +} diff --git a/go.mod b/go.mod index b69db858b..da779ced8 100644 --- a/go.mod +++ b/go.mod @@ -20,6 +20,8 @@ require ( github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.0.1 github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.2.0 + github.com/lestrrat-go/jwx/v2 v2.1.6 + github.com/mark3labs/mcp-go v0.43.0 github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f github.com/opentracing/opentracing-go v1.2.0 github.com/peterbourgon/ff/v3 v3.4.0 @@ -60,13 +62,28 @@ require ( require ( github.com/42wim/httpsig v1.2.2 // indirect + github.com/bahlo/generic-list-go v0.2.0 // indirect + github.com/buger/jsonparser v1.1.1 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/davidmz/go-pageant v1.0.2 // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect github.com/go-fed/httpsig v1.1.0 // indirect + github.com/goccy/go-json v0.10.3 // indirect github.com/hashicorp/go-version v1.7.0 // indirect + github.com/invopop/jsonschema v0.13.0 // indirect github.com/kylelemons/godebug v1.1.0 // indirect + github.com/lestrrat-go/blackmagic v1.0.3 // indirect + github.com/lestrrat-go/httpcc v1.0.1 // indirect + github.com/lestrrat-go/httprc v1.0.6 // indirect + github.com/lestrrat-go/iter v1.0.2 // indirect + github.com/lestrrat-go/option v1.0.1 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/segmentio/asm v1.2.0 // indirect + github.com/spf13/cast v1.7.1 // indirect github.com/tetratelabs/wazero v1.9.0 // indirect github.com/wasilibs/wazero-helpers v0.0.0-20240620070341-3dff1577cd52 // indirect + github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect ) diff --git a/go.sum b/go.sum index 9129cfa59..076c3e8be 100644 --- a/go.sum +++ b/go.sum @@ -41,6 +41,8 @@ github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFI github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= +github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= +github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= @@ -49,6 +51,8 @@ github.com/bits-and-blooms/bitset v1.20.0 h1:2F+rfL86jE2d/bmw7OhqUg2Sj/1rURkBn3M github.com/bits-and-blooms/bitset v1.20.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= github.com/bmatcuk/doublestar v1.3.4 h1:gPypJ5xD31uhX6Tf54sDPUOBXTqKH4c9aPY66CyQrS0= github.com/bmatcuk/doublestar v1.3.4/go.mod h1:wiQtGV+rzVYxB7WIlirSN++5HPtPlXEo9MEoZQC/PmE= +github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= +github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= @@ -81,6 +85,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0= github.com/davidmz/go-pageant v1.0.2/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o= @@ -105,6 +111,8 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/fogleman/gg v1.2.1-0.20190220221249-0403632d5b90/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= github.com/frankban/quicktest v1.14.3/go.mod h1:mgiwOwqx65TmIk1wJ6Q7wvnVMocbUorkibMOrVTHZps= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/getsentry/sentry-go v0.31.1 h1:ELVc0h7gwyhnXHDouXkhqTFSO5oslsRDk0++eyE0KJ4= @@ -148,6 +156,8 @@ github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJA github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= github.com/gobwas/ws v1.2.1/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY= +github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA= +github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= @@ -219,6 +229,8 @@ github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSo github.com/hexops/valast v1.4.3 h1:oBoGERMJh6UZdRc6cduE1CTPK+VAdXA59Y1HFgu3sm0= github.com/hexops/valast v1.4.3/go.mod h1:Iqx2kLj3Jn47wuXpj3wX40xn6F93QNFBHuiKBerkTGA= github.com/ianlancetaylor/demangle v0.0.0-20230524184225-eabc099b10ab/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw= +github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= +github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= @@ -241,7 +253,22 @@ github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs= +github.com/lestrrat-go/blackmagic v1.0.3 h1:94HXkVLxkZO9vJI/w2u1T0DAoprShFd13xtnSINtDWs= +github.com/lestrrat-go/blackmagic v1.0.3/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw= +github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= +github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= +github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCGW8k= +github.com/lestrrat-go/httprc v1.0.6/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo= +github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI= +github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4= +github.com/lestrrat-go/jwx/v2 v2.1.6 h1:hxM1gfDILk/l5ylers6BX/Eq1m/pnxe9NBwW6lVfecA= +github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU= +github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= +github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mark3labs/mcp-go v0.43.0 h1:lgiKcWMddh4sngbU+hoWOZ9iAe/qp/m851RQpj3Y7jA= +github.com/mark3labs/mcp-go v0.43.0/go.mod h1:YnJfOL382MIWDx1kMY+2zsRHU/q78dBg9aFb8W6Thdw= github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= @@ -309,6 +336,8 @@ github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0t github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= +github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI= @@ -323,6 +352,8 @@ github.com/sourcegraph/log v0.0.0-20241024013702-574f7079c888 h1:9PUH8Hn8mVhPTtR github.com/sourcegraph/log v0.0.0-20241024013702-574f7079c888/go.mod h1:IDp09QkoqS8Z3CyN2RW6vXjgABkNpDbyjLIHNQwQ8P8= github.com/sourcegraph/mountinfo v0.0.0-20251117172727-e9f6a87f579c h1:6F/5RpKA1mga6IAHIYsiaCzQF4hzZEAifM08oO7BFoE= github.com/sourcegraph/mountinfo v0.0.0-20251117172727-e9f6a87f579c/go.mod h1:ghoEiutaNVERt2cu5q/bU3HOo29AHGSPrRZE1sOaA0w= +github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= +github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= @@ -332,6 +363,7 @@ github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= @@ -348,6 +380,8 @@ github.com/wasilibs/go-re2 v1.10.0 h1:vQZEBYZOCA9jdBMmrO4+CvqyCj0x4OomXTJ4a5/urQ github.com/wasilibs/go-re2 v1.10.0/go.mod h1:k+5XqO2bCJS+QpGOnqugyfwC04nw0jaglmjrrkG8U6o= github.com/wasilibs/wazero-helpers v0.0.0-20240620070341-3dff1577cd52 h1:OvLBa8SqJnZ6P+mjlzc2K7PM22rRUPE1x32G9DTPrC4= github.com/wasilibs/wazero-helpers v0.0.0-20240620070341-3dff1577cd52/go.mod h1:jMeV4Vpbi8osrE/pKUxRZkVaA0EX7NZN0A9/oRzgpgY= +github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= +github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= @@ -357,6 +391,8 @@ github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHo github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= diff --git a/mcp-local-testing.md b/mcp-local-testing.md new file mode 100644 index 000000000..058c29579 --- /dev/null +++ b/mcp-local-testing.md @@ -0,0 +1,89 @@ +# MCP Local Testing + +End-to-end guide to test the MCP OAuth flow locally using Docker. + +## Prerequisites + +- Docker +- OKTA_BASE_URL +- `ZOEKT_OKTA_CLIENT_ID` — the Okta app client ID + +## Variables + +Adjust these to your environment: + +```bash +REPO_TO_INDEX=~/repositories/zoekt # local repo to index +INDEX_VOLUME=zoekt-index-test # Docker volume name for the index +OKTA_BASE_URL= +ZOEKT_OKTA_CLIENT_ID= +CALLBACK_PORT=9877 # must be registered as redirect URI in Okta app +``` + +## 1. Build the image + +```bash +cd ~/repositories/zoekt +docker build -f Dockerfile -t zoekt-test . +``` + +## 2. Index a local repository (one-shot) + +```bash +docker run --rm \ + -v ${INDEX_VOLUME}:/data/index \ + -v ${REPO_TO_INDEX}:/repo \ + --entrypoint zoekt-git-index \ + zoekt-test \ + -index /data/index /repo +``` + +This writes index files into the `${INDEX_VOLUME}` Docker volume. Re-run whenever the repo changes. + +## 3. Start the server + +```bash +docker run --rm -it \ + -p 8080:8080 \ + -e ZOEKT_OKTA_BASE_URL=${OKTA_BASE_URL} \ + -e SRC_LOG_LEVEL=info \ + -v ${INDEX_VOLUME}:/data/index \ + --entrypoint zoekt-webserver \ + zoekt-test \ + -index /data/index -listen :8080 +``` + +## 4. Verify the server is up + +```bash +# OAuth discovery endpoint — should return Okta metadata +curl http://localhost:8080/.well-known/oauth-authorization-server | jq .issuer + +# MCP endpoint without token — should return 401 +curl http://localhost:8080/mcp +``` + +## 5. Register the MCP server in Claude Code + +```bash + claude mcp add-json zoekt-search \ + '{"type":"http","url":"http://localhost:8080/mcp","oauth":{"clientId":"'${ZOEKT_OKTA_CLIENT_ID}'","callbackPort":'${CALLBACK_PORT}',"scopes":"openid profile offline_access"}} +``` + +Click **Authenticate** in Claude Code to trigger the Okta PKCE flow. Once authenticated, the `zoekt_search` tool is available. + +## 6. Test the zoekt_search tool + +In Claude Code, run: + +``` +use zoekt search r:zoekt file:README +``` + +You should get results listing README files from the indexed repository. + +To remove the MCP server: + +```bash +claude mcp remove zoekt-search +``` \ No newline at end of file