chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,352 @@
|
||||
package github
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/github/github-mcp-server/pkg/lockdown"
|
||||
"github.com/github/github-mcp-server/pkg/observability"
|
||||
"github.com/github/github-mcp-server/pkg/observability/metrics"
|
||||
"github.com/github/github-mcp-server/pkg/raw"
|
||||
"github.com/github/github-mcp-server/pkg/translations"
|
||||
gogithub "github.com/google/go-github/v89/github"
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
"github.com/shurcooL/githubv4"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// stubDeps is a test helper that implements ToolDependencies with configurable behavior.
|
||||
// Use this when you need to test error paths or when you need closure-based client creation.
|
||||
type stubDeps struct {
|
||||
clientFn func(context.Context) (*gogithub.Client, error)
|
||||
gqlClientFn func(context.Context) (*githubv4.Client, error)
|
||||
rawClientFn func(context.Context) (*raw.Client, error)
|
||||
|
||||
repoAccessCache *lockdown.RepoAccessCache
|
||||
t translations.TranslationHelperFunc
|
||||
flags FeatureFlags
|
||||
contentWindowSize int
|
||||
obsv observability.Exporters
|
||||
}
|
||||
|
||||
func (s stubDeps) GetClient(ctx context.Context) (*gogithub.Client, error) {
|
||||
if s.clientFn != nil {
|
||||
return s.clientFn(ctx)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s stubDeps) GetGQLClient(ctx context.Context) (*githubv4.Client, error) {
|
||||
if s.gqlClientFn != nil {
|
||||
return s.gqlClientFn(ctx)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s stubDeps) GetRawClient(ctx context.Context) (*raw.Client, error) {
|
||||
if s.rawClientFn != nil {
|
||||
return s.rawClientFn(ctx)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s stubDeps) GetRepoAccessCache(_ context.Context) (*lockdown.RepoAccessCache, error) {
|
||||
return s.repoAccessCache, nil
|
||||
}
|
||||
func (s stubDeps) GetT() translations.TranslationHelperFunc { return s.t }
|
||||
func (s stubDeps) GetFlags(_ context.Context) FeatureFlags { return s.flags }
|
||||
func (s stubDeps) GetContentWindowSize() int { return s.contentWindowSize }
|
||||
func (s stubDeps) IsFeatureEnabled(_ context.Context, _ string) bool { return false }
|
||||
func (s stubDeps) Logger(_ context.Context) *slog.Logger {
|
||||
return s.obsv.Logger()
|
||||
}
|
||||
func (s stubDeps) Metrics(ctx context.Context) metrics.Metrics {
|
||||
return s.obsv.Metrics(ctx)
|
||||
}
|
||||
|
||||
// Helper functions to create stub client functions for error testing
|
||||
|
||||
// stubExporters returns a discard-logger + noop-metrics Exporters for tests.
|
||||
func stubExporters() observability.Exporters {
|
||||
obs, _ := observability.NewExporters(slog.New(slog.DiscardHandler), metrics.NewNoopMetrics())
|
||||
return obs
|
||||
}
|
||||
|
||||
func stubClientFnFromHTTP(t *testing.T, httpClient *http.Client) func(context.Context) (*gogithub.Client, error) {
|
||||
t.Helper()
|
||||
return func(_ context.Context) (*gogithub.Client, error) {
|
||||
return mustNewGHClient(t, httpClient), nil
|
||||
}
|
||||
}
|
||||
|
||||
func stubClientFnErr(errMsg string) func(context.Context) (*gogithub.Client, error) {
|
||||
return func(_ context.Context) (*gogithub.Client, error) {
|
||||
return nil, errors.New(errMsg)
|
||||
}
|
||||
}
|
||||
|
||||
func stubGQLClientFnErr(errMsg string) func(context.Context) (*githubv4.Client, error) {
|
||||
return func(_ context.Context) (*githubv4.Client, error) {
|
||||
return nil, errors.New(errMsg)
|
||||
}
|
||||
}
|
||||
|
||||
func stubRepoAccessCache(restClient *gogithub.Client, ttl time.Duration) *lockdown.RepoAccessCache {
|
||||
cacheName := fmt.Sprintf("repo-access-cache-test-%d", time.Now().UnixNano())
|
||||
return lockdown.NewRepoAccessCache(
|
||||
githubv4.NewClient(newRepoAccessHTTPClient()),
|
||||
restClient,
|
||||
lockdown.WithTTL(ttl),
|
||||
lockdown.WithCacheName(cacheName),
|
||||
)
|
||||
}
|
||||
|
||||
func mockRESTPermissionServer(t *testing.T, defaultPerm string, overrides map[string]string) *gogithub.Client {
|
||||
t.Helper()
|
||||
return mustNewGHClient(t, MockHTTPClientWithHandler(func(w http.ResponseWriter, r *http.Request) {
|
||||
perm := defaultPerm
|
||||
for user, p := range overrides {
|
||||
if strings.Contains(r.URL.Path, "/collaborators/"+user+"/") {
|
||||
perm = p
|
||||
break
|
||||
}
|
||||
}
|
||||
resp := gogithub.RepositoryPermissionLevel{
|
||||
Permission: gogithub.Ptr(perm),
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
}
|
||||
|
||||
func stubFeatureFlags(enabledFlags map[string]bool) FeatureFlags {
|
||||
return FeatureFlags{
|
||||
LockdownMode: enabledFlags["lockdown-mode"],
|
||||
}
|
||||
}
|
||||
|
||||
func badRequestHandler(msg string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, _ *http.Request) {
|
||||
structuredErrorResponse := gogithub.ErrorResponse{
|
||||
Message: msg,
|
||||
}
|
||||
|
||||
b, err := json.Marshal(structuredErrorResponse)
|
||||
if err != nil {
|
||||
http.Error(w, "failed to marshal error response", http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
http.Error(w, string(b), http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewMCPServer_CreatesSuccessfully verifies that the server can be created
|
||||
// with the deps injection middleware properly configured.
|
||||
func TestNewMCPServer_CreatesSuccessfully(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Create a minimal server configuration
|
||||
cfg := MCPServerConfig{
|
||||
Version: "test",
|
||||
Host: "", // defaults to github.com
|
||||
Token: "test-token",
|
||||
EnabledToolsets: []string{"context"},
|
||||
ReadOnly: false,
|
||||
Translator: translations.NullTranslationHelper,
|
||||
ContentWindowSize: 5000,
|
||||
LockdownMode: false,
|
||||
}
|
||||
|
||||
deps := stubDeps{obsv: stubExporters()}
|
||||
|
||||
// Build inventory
|
||||
inv, err := NewInventory(cfg.Translator).
|
||||
WithDeprecatedAliases(DeprecatedToolAliases).
|
||||
WithToolsets(cfg.EnabledToolsets).
|
||||
Build()
|
||||
|
||||
require.NoError(t, err, "expected inventory build to succeed")
|
||||
|
||||
// Create the server
|
||||
server, err := NewMCPServer(context.Background(), &cfg, deps, inv)
|
||||
require.NoError(t, err, "expected server creation to succeed")
|
||||
require.NotNil(t, server, "expected server to be non-nil")
|
||||
|
||||
// The fact that the server was created successfully indicates that:
|
||||
// 1. The deps injection middleware is properly added
|
||||
// 2. Tools can be registered without panicking
|
||||
//
|
||||
// If the middleware wasn't properly added, tool calls would panic with
|
||||
// "ToolDependencies not found in context" when executed.
|
||||
//
|
||||
// The actual middleware functionality and tool execution with ContextWithDeps
|
||||
// is already tested in pkg/github/*_test.go.
|
||||
}
|
||||
|
||||
// TestNewServer_NameAndTitleViaTranslation verifies that server name and title
|
||||
// can be overridden via the translation helper (GITHUB_MCP_SERVER_NAME /
|
||||
// GITHUB_MCP_SERVER_TITLE env vars or github-mcp-server-config.json) and
|
||||
// fall back to sensible defaults when not overridden.
|
||||
func TestNewServer_NameAndTitleViaTranslation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
translator translations.TranslationHelperFunc
|
||||
expectedName string
|
||||
expectedTitle string
|
||||
}{
|
||||
{
|
||||
name: "defaults when using NullTranslationHelper",
|
||||
translator: translations.NullTranslationHelper,
|
||||
expectedName: "github-mcp-server",
|
||||
expectedTitle: "GitHub MCP Server",
|
||||
},
|
||||
{
|
||||
name: "custom name and title via translator",
|
||||
translator: func(key, defaultValue string) string {
|
||||
switch key {
|
||||
case "SERVER_NAME":
|
||||
return "my-github-server"
|
||||
case "SERVER_TITLE":
|
||||
return "My GitHub MCP Server"
|
||||
default:
|
||||
return defaultValue
|
||||
}
|
||||
},
|
||||
expectedName: "my-github-server",
|
||||
expectedTitle: "My GitHub MCP Server",
|
||||
},
|
||||
{
|
||||
name: "custom name only via translator",
|
||||
translator: func(key, defaultValue string) string {
|
||||
if key == "SERVER_NAME" {
|
||||
return "ghes-server"
|
||||
}
|
||||
return defaultValue
|
||||
},
|
||||
expectedName: "ghes-server",
|
||||
expectedTitle: "GitHub MCP Server",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
srv := NewServer("v1.0.0", tt.translator("SERVER_NAME", "github-mcp-server"), tt.translator("SERVER_TITLE", "GitHub MCP Server"), nil)
|
||||
require.NotNil(t, srv)
|
||||
|
||||
// Connect a client to retrieve the initialize result and verify ServerInfo.
|
||||
st, ct := mcp.NewInMemoryTransports()
|
||||
client := mcp.NewClient(&mcp.Implementation{Name: "test-client"}, nil)
|
||||
|
||||
type clientResult struct {
|
||||
result *mcp.InitializeResult
|
||||
err error
|
||||
}
|
||||
clientResultCh := make(chan clientResult, 1)
|
||||
go func() {
|
||||
cs, err := client.Connect(context.Background(), ct, nil)
|
||||
if err != nil {
|
||||
clientResultCh <- clientResult{err: err}
|
||||
return
|
||||
}
|
||||
t.Cleanup(func() { _ = cs.Close() })
|
||||
clientResultCh <- clientResult{result: cs.InitializeResult()}
|
||||
}()
|
||||
|
||||
ss, err := srv.Connect(context.Background(), st, nil)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = ss.Close() })
|
||||
|
||||
got := <-clientResultCh
|
||||
require.NoError(t, got.err)
|
||||
require.NotNil(t, got.result)
|
||||
require.NotNil(t, got.result.ServerInfo)
|
||||
assert.Equal(t, tt.expectedName, got.result.ServerInfo.Name)
|
||||
assert.Equal(t, tt.expectedTitle, got.result.ServerInfo.Title)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveEnabledToolsets verifies the toolset resolution logic.
|
||||
func TestResolveEnabledToolsets(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg MCPServerConfig
|
||||
expectedResult []string
|
||||
}{
|
||||
{
|
||||
name: "nil toolsets and no tools - use defaults",
|
||||
cfg: MCPServerConfig{
|
||||
EnabledToolsets: nil,
|
||||
EnabledTools: nil,
|
||||
},
|
||||
expectedResult: nil, // nil means "use defaults"
|
||||
},
|
||||
{
|
||||
name: "explicit toolsets",
|
||||
cfg: MCPServerConfig{
|
||||
EnabledToolsets: []string{"repos", "issues"},
|
||||
},
|
||||
expectedResult: []string{"repos", "issues"},
|
||||
},
|
||||
{
|
||||
name: "empty toolsets - disable all",
|
||||
cfg: MCPServerConfig{
|
||||
EnabledToolsets: []string{},
|
||||
},
|
||||
expectedResult: []string{},
|
||||
},
|
||||
{
|
||||
name: "specific tools without toolsets - no default toolsets",
|
||||
cfg: MCPServerConfig{
|
||||
EnabledToolsets: nil,
|
||||
EnabledTools: []string{"get_me"},
|
||||
},
|
||||
expectedResult: []string{}, // empty slice when tools specified but no toolsets
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result := ResolvedEnabledToolsets(tc.cfg.EnabledToolsets, tc.cfg.EnabledTools)
|
||||
assert.Equal(t, tc.expectedResult, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompletionsHandler_RejectsMissingRef(t *testing.T) {
|
||||
getClient := func(_ context.Context) (*gogithub.Client, error) {
|
||||
return &gogithub.Client{}, nil
|
||||
}
|
||||
handler := CompletionsHandler(getClient)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
req *mcp.CompleteRequest
|
||||
}{
|
||||
{name: "nil request", req: nil},
|
||||
{name: "nil params", req: &mcp.CompleteRequest{}},
|
||||
{name: "nil ref", req: &mcp.CompleteRequest{Params: &mcp.CompleteParams{}}},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result, err := handler(context.Background(), tc.req)
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
assert.Contains(t, err.Error(), "missing required parameter: ref")
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user