Files
github--github-mcp-server/pkg/github/server_test.go
Matt Holloway dd239d8443 Initial OSS logging adapter for http (#2008)
* initial logging stack for http

* add metrics adapter

* fix linter issues

* make log fields generic

* Update pkg/github/server_test.go

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Remove unused SlogMetrics adapter

The slog-based metrics adapter was never used — OSS always uses
NoopMetrics and the remote server has its own DataDog-backed adapter.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Update pkg/github/dependencies.go

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* fmt

* change to use slog

* address feedback

* rename noop adapter to noop sink

* Update pkg/http/server.go

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* [WIP] [WIP] Address feedback on OSS logging adapter for http implementation (#2264)

* Initial plan

* Fix BaseDeps.Logger and BaseDeps.Metrics to return safe defaults when Obsv is nil

Agent-Logs-Url: https://github.com/github/github-mcp-server/sessions/53221b0b-abb4-4138-a147-3ce9e13b379a

Co-authored-by: mattdholloway <918573+mattdholloway@users.noreply.github.com>

* Fix nil metrics in server.go by passing metrics.NewNoopMetrics() to NewExporters

Agent-Logs-Url: https://github.com/github/github-mcp-server/sessions/53221b0b-abb4-4138-a147-3ce9e13b379a

Co-authored-by: mattdholloway <918573+mattdholloway@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: mattdholloway <918573+mattdholloway@users.noreply.github.com>
Co-authored-by: Matt Holloway <mattdholloway@github.com>

* replace nil with stubs

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
2026-03-31 13:10:22 +01:00

327 lines
9.9 KiB
Go

package github
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"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/v82/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(httpClient *http.Client) func(context.Context) (*gogithub.Client, error) {
return func(_ context.Context) (*gogithub.Client, error) {
return gogithub.NewClient(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(client *githubv4.Client, ttl time.Duration) *lockdown.RepoAccessCache {
cacheName := fmt.Sprintf("repo-access-cache-test-%d", time.Now().UnixNano())
return lockdown.GetInstance(client, lockdown.WithTTL(ttl), lockdown.WithCacheName(cacheName))
}
func stubFeatureFlags(enabledFlags map[string]bool) FeatureFlags {
return FeatureFlags{
LockdownMode: enabledFlags["lockdown-mode"],
InsidersMode: enabledFlags["insiders-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,
InsidersMode: 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 without dynamic mode and no tools - use defaults",
cfg: MCPServerConfig{
EnabledToolsets: nil,
DynamicToolsets: false,
EnabledTools: nil,
},
expectedResult: nil, // nil means "use defaults"
},
{
name: "nil toolsets with dynamic mode - start empty",
cfg: MCPServerConfig{
EnabledToolsets: nil,
DynamicToolsets: true,
EnabledTools: nil,
},
expectedResult: []string{}, // empty slice means no toolsets
},
{
name: "explicit toolsets",
cfg: MCPServerConfig{
EnabledToolsets: []string{"repos", "issues"},
DynamicToolsets: false,
},
expectedResult: []string{"repos", "issues"},
},
{
name: "empty toolsets - disable all",
cfg: MCPServerConfig{
EnabledToolsets: []string{},
DynamicToolsets: false,
},
expectedResult: []string{}, // empty slice means no toolsets
},
{
name: "specific tools without toolsets - no default toolsets",
cfg: MCPServerConfig{
EnabledToolsets: nil,
DynamicToolsets: false,
EnabledTools: []string{"get_me"},
},
expectedResult: []string{}, // empty slice when tools specified but no toolsets
},
{
name: "dynamic mode with explicit toolsets removes all and default",
cfg: MCPServerConfig{
EnabledToolsets: []string{"all", "repos"},
DynamicToolsets: true,
},
expectedResult: []string{"repos"}, // "all" is removed in dynamic mode
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result := ResolvedEnabledToolsets(tc.cfg.DynamicToolsets, tc.cfg.EnabledToolsets, tc.cfg.EnabledTools)
assert.Equal(t, tc.expectedResult, result)
})
}
}