From 590834e4654fe947df53ad696eef2de364aacfea Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Dec 2025 09:25:14 +0000 Subject: [PATCH 1/4] Initial plan From b7243b8251a9e4a32cb852dc63beb8627a3eebe0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Dec 2025 09:37:32 +0000 Subject: [PATCH 2/4] Add session info metadata to initialize response - Add middleware to enrich InitializeResult with session information - Include user details (get_me response) in authenticated mode - Include enabled toolsets, tools, read-only mode, and lockdown mode - Handle both authenticated and unauthenticated modes appropriately - Add comprehensive unit tests for the new functionality - Remove suggestion to call get_me from auth_login success message Co-authored-by: SamMorrowDrums <4811358+SamMorrowDrums@users.noreply.github.com> --- internal/ghmcp/server.go | 162 ++++++++++++++++++ internal/ghmcp/server_test.go | 299 ++++++++++++++++++++++++++++++++++ pkg/github/auth_tools.go | 4 +- 3 files changed, 462 insertions(+), 3 deletions(-) diff --git a/internal/ghmcp/server.go b/internal/ghmcp/server.go index 7fe526869..189b3f22a 100644 --- a/internal/ghmcp/server.go +++ b/internal/ghmcp/server.go @@ -216,6 +216,7 @@ func NewMCPServer(cfg MCPServerConfig) (*mcp.Server, error) { // Add middlewares ghServer.AddReceivingMiddleware(addGitHubAPIErrorToContext) ghServer.AddReceivingMiddleware(addUserAgentsMiddleware(cfg, clients.rest, clients.gqlHTTP)) + ghServer.AddReceivingMiddleware(addSessionInfoMiddleware(cfg, clients.rest, enabledToolsets, instructionToolsets)) // Create dependencies for tool handlers deps := github.NewBaseDeps( @@ -346,6 +347,14 @@ func NewUnauthenticatedMCPServer(cfg MCPServerConfig) (*UnauthenticatedServerRes // Add error context middleware ghServer.AddReceivingMiddleware(addGitHubAPIErrorToContext) + // Add session info middleware for unauthenticated mode + // This will show configuration but no user info until authenticated + instructionToolsets := enabledToolsets + if instructionToolsets == nil { + instructionToolsets = github.GetDefaultToolsetIDs() + } + ghServer.AddReceivingMiddleware(addUnauthenticatedSessionInfoMiddleware(cfg, enabledToolsets, instructionToolsets)) + // Create auth tool dependencies with a callback for when auth completes authDeps := github.AuthToolDependencies{ AuthManager: authManager, @@ -841,3 +850,156 @@ func addUserAgentsMiddleware(cfg MCPServerConfig, restClient *gogithub.Client, g } } } + +// addSessionInfoMiddleware enriches the InitializeResult with session information +// including user details, enabled toolsets, and configuration flags. +func addSessionInfoMiddleware(cfg MCPServerConfig, restClient *gogithub.Client, enabledToolsets []string, instructionToolsets []string) func(next mcp.MethodHandler) mcp.MethodHandler { + return func(next mcp.MethodHandler) mcp.MethodHandler { + return func(ctx context.Context, method string, request mcp.Request) (result mcp.Result, err error) { + // Only intercept initialize method + if method != "initialize" { + return next(ctx, method, request) + } + + // Call the next handler to get the InitializeResult + result, err = next(ctx, method, request) + if err != nil { + return result, err + } + + // Cast to InitializeResult to add metadata + initResult, ok := result.(*mcp.InitializeResult) + if !ok { + // If we can't cast, just return the original result + return result, err + } + + // Build session info metadata + sessionInfo := make(map[string]any) + + // Add configuration information + sessionInfo["readOnlyMode"] = cfg.ReadOnly + sessionInfo["lockdownMode"] = cfg.LockdownMode + sessionInfo["dynamicToolsets"] = cfg.DynamicToolsets + + // Add toolsets information + switch { + case enabledToolsets == nil: + // nil means "use defaults" + sessionInfo["enabledToolsets"] = instructionToolsets + sessionInfo["toolsetsMode"] = "default" + case len(enabledToolsets) == 0: + sessionInfo["enabledToolsets"] = []string{} + sessionInfo["toolsetsMode"] = "none" + default: + sessionInfo["enabledToolsets"] = enabledToolsets + sessionInfo["toolsetsMode"] = "explicit" + } + + // Add enabled tools if specified + if len(cfg.EnabledTools) > 0 { + sessionInfo["enabledTools"] = cfg.EnabledTools + } + + // Try to fetch user information (get_me equivalent) + // If it fails, we simply omit it from the session info + if user, _, err := restClient.Users.Get(ctx, ""); err == nil && user != nil { + userInfo := map[string]any{ + "login": user.GetLogin(), + "id": user.GetID(), + "profileURL": user.GetHTMLURL(), + "avatarURL": user.GetAvatarURL(), + } + + // Add optional fields if they exist + if name := user.GetName(); name != "" { + userInfo["name"] = name + } + if email := user.GetEmail(); email != "" { + userInfo["email"] = email + } + if bio := user.GetBio(); bio != "" { + userInfo["bio"] = bio + } + if company := user.GetCompany(); company != "" { + userInfo["company"] = company + } + if location := user.GetLocation(); location != "" { + userInfo["location"] = location + } + + sessionInfo["user"] = userInfo + } + + // Set the metadata on the InitializeResult + if initResult.Meta == nil { + initResult.Meta = make(mcp.Meta) + } + initResult.Meta["sessionInfo"] = sessionInfo + + return initResult, nil + } + } +} + +// addUnauthenticatedSessionInfoMiddleware enriches the InitializeResult with session information +// for unauthenticated servers. This shows configuration but no user info until authenticated. +func addUnauthenticatedSessionInfoMiddleware(cfg MCPServerConfig, enabledToolsets []string, instructionToolsets []string) func(next mcp.MethodHandler) mcp.MethodHandler { + return func(next mcp.MethodHandler) mcp.MethodHandler { + return func(ctx context.Context, method string, request mcp.Request) (result mcp.Result, err error) { + // Only intercept initialize method + if method != "initialize" { + return next(ctx, method, request) + } + + // Call the next handler to get the InitializeResult + result, err = next(ctx, method, request) + if err != nil { + return result, err + } + + // Cast to InitializeResult to add metadata + initResult, ok := result.(*mcp.InitializeResult) + if !ok { + // If we can't cast, just return the original result + return result, err + } + + // Build session info metadata (without user info for unauthenticated mode) + sessionInfo := make(map[string]any) + + // Add configuration information + sessionInfo["readOnlyMode"] = cfg.ReadOnly + sessionInfo["lockdownMode"] = cfg.LockdownMode + sessionInfo["dynamicToolsets"] = cfg.DynamicToolsets + sessionInfo["authenticated"] = false + + // Add toolsets information + switch { + case enabledToolsets == nil: + // nil means "use defaults" + sessionInfo["enabledToolsets"] = instructionToolsets + sessionInfo["toolsetsMode"] = "default" + case len(enabledToolsets) == 0: + sessionInfo["enabledToolsets"] = []string{} + sessionInfo["toolsetsMode"] = "none" + default: + sessionInfo["enabledToolsets"] = enabledToolsets + sessionInfo["toolsetsMode"] = "explicit" + } + + // Add enabled tools if specified + if len(cfg.EnabledTools) > 0 { + sessionInfo["enabledTools"] = cfg.EnabledTools + } + + // Set the metadata on the InitializeResult + if initResult.Meta == nil { + initResult.Meta = make(mcp.Meta) + } + initResult.Meta["sessionInfo"] = sessionInfo + + return initResult, nil + } + } +} diff --git a/internal/ghmcp/server_test.go b/internal/ghmcp/server_test.go index 04c0989d4..ef084a7b0 100644 --- a/internal/ghmcp/server_test.go +++ b/internal/ghmcp/server_test.go @@ -1,9 +1,14 @@ package ghmcp import ( + "context" + "net/http" "testing" "github.com/github/github-mcp-server/pkg/translations" + gogithub "github.com/google/go-github/v79/github" + "github.com/migueleliasweb/go-github-mock/src/mock" + "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -41,6 +46,300 @@ func TestNewMCPServer_CreatesSuccessfully(t *testing.T) { // is already tested in pkg/github/*_test.go. } +// TestSessionInfoMiddleware_AddsMetadataToInitializeResult verifies that the +// session info middleware enriches the InitializeResult with session metadata. +func TestSessionInfoMiddleware_AddsMetadataToInitializeResult(t *testing.T) { + t.Parallel() + + // Create test cases for different configurations + tests := []struct { + name string + cfg MCPServerConfig + enabledToolsets []string + instructionToolsets []string + expectedReadOnly bool + expectedLockdown bool + expectedDynamic bool + expectedToolsetsMode string + expectedToolsetsCount int + expectedTools []string + }{ + { + name: "default configuration", + cfg: MCPServerConfig{ + ReadOnly: false, + LockdownMode: false, + EnabledTools: nil, + EnabledToolsets: nil, + }, + enabledToolsets: nil, + instructionToolsets: []string{"default"}, + expectedReadOnly: false, + expectedLockdown: false, + expectedDynamic: false, + expectedToolsetsMode: "default", + expectedToolsetsCount: 1, + expectedTools: nil, + }, + { + name: "read-only with lockdown", + cfg: MCPServerConfig{ + ReadOnly: true, + LockdownMode: true, + EnabledTools: []string{"get_me", "list_repos"}, + EnabledToolsets: []string{"repos", "issues"}, + }, + enabledToolsets: []string{"repos", "issues"}, + instructionToolsets: []string{"repos", "issues"}, + expectedReadOnly: true, + expectedLockdown: true, + expectedDynamic: false, + expectedToolsetsMode: "explicit", + expectedToolsetsCount: 2, + expectedTools: []string{"get_me", "list_repos"}, + }, + { + name: "dynamic toolsets mode", + cfg: MCPServerConfig{ + DynamicToolsets: true, + ReadOnly: false, + LockdownMode: false, + EnabledToolsets: []string{}, + }, + enabledToolsets: []string{}, + instructionToolsets: []string{}, + expectedReadOnly: false, + expectedLockdown: false, + expectedDynamic: true, + expectedToolsetsMode: "none", + expectedToolsetsCount: 0, + expectedTools: nil, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Create a new mock client for each test case + mockClient := mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetUser, + gogithub.User{ + Login: gogithub.String("testuser"), + ID: gogithub.Int64(12345), + HTMLURL: gogithub.String("https://github.com/testuser"), + AvatarURL: gogithub.String("https://avatars.githubusercontent.com/u/12345"), + Name: gogithub.String("Test User"), + Email: gogithub.String("test@example.com"), + Bio: gogithub.String("Test bio"), + Company: gogithub.String("Test Company"), + Location: gogithub.String("Test Location"), + }, + ), + ) + + // Create a GitHub client with the mock + restClient := gogithub.NewClient(mockClient).WithAuthToken("test-token") + // Create middleware + middleware := addSessionInfoMiddleware(tc.cfg, restClient, tc.enabledToolsets, tc.instructionToolsets) + + // Create a mock handler that returns a valid InitializeResult + mockHandler := func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) { + return &mcp.InitializeResult{ + ProtocolVersion: "2024-11-05", + Capabilities: &mcp.ServerCapabilities{}, + ServerInfo: &mcp.Implementation{ + Name: "test-server", + Version: "1.0.0", + }, + }, nil + } + + // Wrap with middleware + handler := middleware(mockHandler) + + // Call with initialize method + result, err := handler(context.Background(), "initialize", &mcp.InitializeRequest{}) + require.NoError(t, err) + require.NotNil(t, result) + + // Cast to InitializeResult + initResult, ok := result.(*mcp.InitializeResult) + require.True(t, ok, "result should be InitializeResult") + + // Verify metadata exists + require.NotNil(t, initResult.Meta) + sessionInfo, exists := initResult.Meta["sessionInfo"] + require.True(t, exists, "sessionInfo should exist in metadata") + + // Cast sessionInfo to map + sessionInfoMap, ok := sessionInfo.(map[string]any) + require.True(t, ok, "sessionInfo should be a map") + + // Verify configuration flags + assert.Equal(t, tc.expectedReadOnly, sessionInfoMap["readOnlyMode"]) + assert.Equal(t, tc.expectedLockdown, sessionInfoMap["lockdownMode"]) + assert.Equal(t, tc.expectedDynamic, sessionInfoMap["dynamicToolsets"]) + assert.Equal(t, tc.expectedToolsetsMode, sessionInfoMap["toolsetsMode"]) + + // Verify toolsets + enabledToolsets, ok := sessionInfoMap["enabledToolsets"].([]string) + require.True(t, ok, "enabledToolsets should be a string slice") + assert.Len(t, enabledToolsets, tc.expectedToolsetsCount) + + // Verify enabled tools if specified + if tc.expectedTools != nil { + tools, ok := sessionInfoMap["enabledTools"].([]string) + require.True(t, ok, "enabledTools should be a string slice") + assert.Equal(t, tc.expectedTools, tools) + } + + // Verify user info exists (since we mocked a successful API call) + userInfo, exists := sessionInfoMap["user"] + require.True(t, exists, "user info should exist") + userInfoMap, ok := userInfo.(map[string]any) + require.True(t, ok, "user info should be a map") + assert.Equal(t, "testuser", userInfoMap["login"]) + assert.Equal(t, int64(12345), userInfoMap["id"]) + assert.Equal(t, "https://github.com/testuser", userInfoMap["profileURL"]) + assert.Equal(t, "Test User", userInfoMap["name"]) + assert.Equal(t, "test@example.com", userInfoMap["email"]) + }) + } +} + +// TestSessionInfoMiddleware_OmitsUserOnAPIFailure verifies that when the +// get_me API call fails, the user info is omitted from session info. +func TestSessionInfoMiddleware_OmitsUserOnAPIFailure(t *testing.T) { + t.Parallel() + + // Mock GitHub API to return an error + mockClient := mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetUser, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + }), + ), + ) + + // Create a GitHub client with the mock + restClient := gogithub.NewClient(mockClient).WithAuthToken("invalid-token") + + cfg := MCPServerConfig{ + ReadOnly: false, + LockdownMode: false, + } + + // Create middleware + middleware := addSessionInfoMiddleware(cfg, restClient, []string{"context"}, []string{"context"}) + + // Create a mock handler + mockHandler := func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) { + return &mcp.InitializeResult{ + ProtocolVersion: "2024-11-05", + Capabilities: &mcp.ServerCapabilities{}, + ServerInfo: &mcp.Implementation{ + Name: "test-server", + Version: "1.0.0", + }, + }, nil + } + + // Wrap with middleware + handler := middleware(mockHandler) + + // Call with initialize method + result, err := handler(context.Background(), "initialize", &mcp.InitializeRequest{}) + require.NoError(t, err) + require.NotNil(t, result) + + // Cast to InitializeResult + initResult, ok := result.(*mcp.InitializeResult) + require.True(t, ok) + + // Verify metadata exists + require.NotNil(t, initResult.Meta) + sessionInfo, exists := initResult.Meta["sessionInfo"] + require.True(t, exists) + + // Cast sessionInfo to map + sessionInfoMap, ok := sessionInfo.(map[string]any) + require.True(t, ok) + + // Verify user info does NOT exist (API call failed) + _, exists = sessionInfoMap["user"] + assert.False(t, exists, "user info should not exist when API call fails") + + // Verify other fields still exist + assert.NotNil(t, sessionInfoMap["readOnlyMode"]) + assert.NotNil(t, sessionInfoMap["lockdownMode"]) + assert.NotNil(t, sessionInfoMap["enabledToolsets"]) +} + +// TestUnauthenticatedSessionInfoMiddleware verifies that the unauthenticated +// middleware adds session info without user data. +func TestUnauthenticatedSessionInfoMiddleware(t *testing.T) { + t.Parallel() + + cfg := MCPServerConfig{ + ReadOnly: true, + LockdownMode: false, + DynamicToolsets: true, + EnabledTools: []string{"tool1", "tool2"}, + } + + // Create middleware + middleware := addUnauthenticatedSessionInfoMiddleware(cfg, []string{}, []string{}) + + // Create a mock handler + mockHandler := func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) { + return &mcp.InitializeResult{ + ProtocolVersion: "2024-11-05", + Capabilities: &mcp.ServerCapabilities{}, + ServerInfo: &mcp.Implementation{ + Name: "test-server", + Version: "1.0.0", + }, + }, nil + } + + // Wrap with middleware + handler := middleware(mockHandler) + + // Call with initialize method + result, err := handler(context.Background(), "initialize", &mcp.InitializeRequest{}) + require.NoError(t, err) + require.NotNil(t, result) + + // Cast to InitializeResult + initResult, ok := result.(*mcp.InitializeResult) + require.True(t, ok) + + // Verify metadata exists + require.NotNil(t, initResult.Meta) + sessionInfo, exists := initResult.Meta["sessionInfo"] + require.True(t, exists) + + // Cast sessionInfo to map + sessionInfoMap, ok := sessionInfo.(map[string]any) + require.True(t, ok) + + // Verify configuration flags + assert.Equal(t, true, sessionInfoMap["readOnlyMode"]) + assert.Equal(t, false, sessionInfoMap["lockdownMode"]) + assert.Equal(t, true, sessionInfoMap["dynamicToolsets"]) + assert.Equal(t, false, sessionInfoMap["authenticated"]) + + // Verify enabled tools + tools, ok := sessionInfoMap["enabledTools"].([]string) + require.True(t, ok) + assert.Equal(t, []string{"tool1", "tool2"}, tools) + + // Verify user info does NOT exist (unauthenticated mode) + _, exists = sessionInfoMap["user"] + assert.False(t, exists, "user info should not exist in unauthenticated mode") +} + // TestResolveEnabledToolsets verifies the toolset resolution logic. func TestResolveEnabledToolsets(t *testing.T) { t.Parallel() diff --git a/pkg/github/auth_tools.go b/pkg/github/auth_tools.go index 841ef4bc5..d5fd115be 100644 --- a/pkg/github/auth_tools.go +++ b/pkg/github/auth_tools.go @@ -170,7 +170,5 @@ All GitHub tools are now available. You can now: - Create and manage repositories - Work with issues and pull requests - Access your organizations and teams -- And much more, depending on your GitHub configuration - -Call get_me to see who you're logged in as.`), nil +- And much more, depending on your GitHub configuration`), nil } From 525f73be248309e462d3ed99df86f974be6c4147 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Dec 2025 09:44:51 +0000 Subject: [PATCH 3/4] Fix linter issues in session info tests - Replace deprecated gogithub.String/Int64 with gogithub.Ptr - Remove unused parameters from mock handler functions Co-authored-by: SamMorrowDrums <4811358+SamMorrowDrums@users.noreply.github.com> --- internal/ghmcp/server_test.go | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/internal/ghmcp/server_test.go b/internal/ghmcp/server_test.go index ef084a7b0..5e78f425c 100644 --- a/internal/ghmcp/server_test.go +++ b/internal/ghmcp/server_test.go @@ -124,15 +124,15 @@ func TestSessionInfoMiddleware_AddsMetadataToInitializeResult(t *testing.T) { mock.WithRequestMatch( mock.GetUser, gogithub.User{ - Login: gogithub.String("testuser"), - ID: gogithub.Int64(12345), - HTMLURL: gogithub.String("https://github.com/testuser"), - AvatarURL: gogithub.String("https://avatars.githubusercontent.com/u/12345"), - Name: gogithub.String("Test User"), - Email: gogithub.String("test@example.com"), - Bio: gogithub.String("Test bio"), - Company: gogithub.String("Test Company"), - Location: gogithub.String("Test Location"), + Login: gogithub.Ptr("testuser"), + ID: gogithub.Ptr(int64(12345)), + HTMLURL: gogithub.Ptr("https://github.com/testuser"), + AvatarURL: gogithub.Ptr("https://avatars.githubusercontent.com/u/12345"), + Name: gogithub.Ptr("Test User"), + Email: gogithub.Ptr("test@example.com"), + Bio: gogithub.Ptr("Test bio"), + Company: gogithub.Ptr("Test Company"), + Location: gogithub.Ptr("Test Location"), }, ), ) @@ -143,7 +143,7 @@ func TestSessionInfoMiddleware_AddsMetadataToInitializeResult(t *testing.T) { middleware := addSessionInfoMiddleware(tc.cfg, restClient, tc.enabledToolsets, tc.instructionToolsets) // Create a mock handler that returns a valid InitializeResult - mockHandler := func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) { + mockHandler := func(_ context.Context, _ string, _ mcp.Request) (mcp.Result, error) { return &mcp.InitializeResult{ ProtocolVersion: "2024-11-05", Capabilities: &mcp.ServerCapabilities{}, @@ -234,7 +234,7 @@ func TestSessionInfoMiddleware_OmitsUserOnAPIFailure(t *testing.T) { middleware := addSessionInfoMiddleware(cfg, restClient, []string{"context"}, []string{"context"}) // Create a mock handler - mockHandler := func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) { + mockHandler := func(_ context.Context, _ string, _ mcp.Request) (mcp.Result, error) { return &mcp.InitializeResult{ ProtocolVersion: "2024-11-05", Capabilities: &mcp.ServerCapabilities{}, @@ -292,7 +292,7 @@ func TestUnauthenticatedSessionInfoMiddleware(t *testing.T) { middleware := addUnauthenticatedSessionInfoMiddleware(cfg, []string{}, []string{}) // Create a mock handler - mockHandler := func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) { + mockHandler := func(_ context.Context, _ string, _ mcp.Request) (mcp.Result, error) { return &mcp.InitializeResult{ ProtocolVersion: "2024-11-05", Capabilities: &mcp.ServerCapabilities{}, From 6d303967768534f9af2858dae91a460e9f05a6cf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 21 Dec 2025 08:42:49 +0000 Subject: [PATCH 4/4] Add session info to auth_login tool response - Add GetSessionInfo callback to AuthToolDependencies - Fetch user info and configuration after successful authentication - Display comprehensive session context in auth_login success message - Remove unused middleware functions that were adding to _meta - Revert instructions.go to original (no longer modifying instructions) The auth_login tool now provides the model with: - User account details (username, name, email, company, location, profile URL) - Enabled toolsets and tools - Configuration flags (read-only, lockdown, dynamic toolsets) Co-authored-by: SamMorrowDrums <4811358+SamMorrowDrums@users.noreply.github.com> --- internal/ghmcp/server.go | 235 +++++++++----------------- internal/ghmcp/server_test.go | 299 ---------------------------------- pkg/github/auth_tools.go | 20 ++- 3 files changed, 87 insertions(+), 467 deletions(-) diff --git a/internal/ghmcp/server.go b/internal/ghmcp/server.go index 189b3f22a..612e0d1f7 100644 --- a/internal/ghmcp/server.go +++ b/internal/ghmcp/server.go @@ -216,7 +216,6 @@ func NewMCPServer(cfg MCPServerConfig) (*mcp.Server, error) { // Add middlewares ghServer.AddReceivingMiddleware(addGitHubAPIErrorToContext) ghServer.AddReceivingMiddleware(addUserAgentsMiddleware(cfg, clients.rest, clients.gqlHTTP)) - ghServer.AddReceivingMiddleware(addSessionInfoMiddleware(cfg, clients.rest, enabledToolsets, instructionToolsets)) // Create dependencies for tool handlers deps := github.NewBaseDeps( @@ -347,14 +346,6 @@ func NewUnauthenticatedMCPServer(cfg MCPServerConfig) (*UnauthenticatedServerRes // Add error context middleware ghServer.AddReceivingMiddleware(addGitHubAPIErrorToContext) - // Add session info middleware for unauthenticated mode - // This will show configuration but no user info until authenticated - instructionToolsets := enabledToolsets - if instructionToolsets == nil { - instructionToolsets = github.GetDefaultToolsetIDs() - } - ghServer.AddReceivingMiddleware(addUnauthenticatedSessionInfoMiddleware(cfg, enabledToolsets, instructionToolsets)) - // Create auth tool dependencies with a callback for when auth completes authDeps := github.AuthToolDependencies{ AuthManager: authManager, @@ -425,6 +416,79 @@ func NewUnauthenticatedMCPServer(cfg MCPServerConfig) (*UnauthenticatedServerRes cfg.Logger.Info("auth tools removed after successful authentication") } }, + GetSessionInfo: func(ctx context.Context, token string) string { + // Create a temporary client to fetch user info + apiHost, err := parseAPIHost(cfg.Host) + if err != nil { + return "" + } + + tempClient := gogithub.NewClient(nil).WithAuthToken(token) + tempClient.BaseURL = apiHost.baseRESTURL + + // Build session information text + var sessionInfo strings.Builder + + // Fetch and include user information + if user, _, err := tempClient.Users.Get(ctx, ""); err == nil && user != nil { + sessionInfo.WriteString("## Your GitHub Account\n\n") + sessionInfo.WriteString(fmt.Sprintf("**Username:** @%s\n", user.GetLogin())) + if name := user.GetName(); name != "" { + sessionInfo.WriteString(fmt.Sprintf("**Name:** %s\n", name)) + } + if email := user.GetEmail(); email != "" { + sessionInfo.WriteString(fmt.Sprintf("**Email:** %s\n", email)) + } + if company := user.GetCompany(); company != "" { + sessionInfo.WriteString(fmt.Sprintf("**Company:** %s\n", company)) + } + if location := user.GetLocation(); location != "" { + sessionInfo.WriteString(fmt.Sprintf("**Location:** %s\n", location)) + } + sessionInfo.WriteString(fmt.Sprintf("**Profile:** %s\n", user.GetHTMLURL())) + } + + // Add server configuration + sessionInfo.WriteString("\n## Server Configuration\n\n") + + // Determine effective toolsets + var effectiveToolsets []string + if enabledToolsets == nil { + // nil means defaults - expand them here + effectiveToolsets = github.GetDefaultToolsetIDs() + } else { + effectiveToolsets = enabledToolsets + } + + if len(effectiveToolsets) > 0 { + sessionInfo.WriteString(fmt.Sprintf("**Enabled Toolsets:** %s\n", strings.Join(effectiveToolsets, ", "))) + } + + if len(cfg.EnabledTools) > 0 { + sessionInfo.WriteString(fmt.Sprintf("**Enabled Tools:** %s\n", strings.Join(cfg.EnabledTools, ", "))) + } + + // Configuration flags + var configFlags []string + if cfg.ReadOnly { + configFlags = append(configFlags, "Read-only mode (write operations disabled)") + } + if cfg.LockdownMode { + configFlags = append(configFlags, "Lockdown mode (repository access restricted)") + } + if cfg.DynamicToolsets { + configFlags = append(configFlags, "Dynamic toolsets (can be enabled at runtime)") + } + + if len(configFlags) > 0 { + sessionInfo.WriteString("\n**Configuration:**\n") + for _, flag := range configFlags { + sessionInfo.WriteString(fmt.Sprintf("- %s\n", flag)) + } + } + + return sessionInfo.String() + }, } // Register only auth tools @@ -850,156 +914,3 @@ func addUserAgentsMiddleware(cfg MCPServerConfig, restClient *gogithub.Client, g } } } - -// addSessionInfoMiddleware enriches the InitializeResult with session information -// including user details, enabled toolsets, and configuration flags. -func addSessionInfoMiddleware(cfg MCPServerConfig, restClient *gogithub.Client, enabledToolsets []string, instructionToolsets []string) func(next mcp.MethodHandler) mcp.MethodHandler { - return func(next mcp.MethodHandler) mcp.MethodHandler { - return func(ctx context.Context, method string, request mcp.Request) (result mcp.Result, err error) { - // Only intercept initialize method - if method != "initialize" { - return next(ctx, method, request) - } - - // Call the next handler to get the InitializeResult - result, err = next(ctx, method, request) - if err != nil { - return result, err - } - - // Cast to InitializeResult to add metadata - initResult, ok := result.(*mcp.InitializeResult) - if !ok { - // If we can't cast, just return the original result - return result, err - } - - // Build session info metadata - sessionInfo := make(map[string]any) - - // Add configuration information - sessionInfo["readOnlyMode"] = cfg.ReadOnly - sessionInfo["lockdownMode"] = cfg.LockdownMode - sessionInfo["dynamicToolsets"] = cfg.DynamicToolsets - - // Add toolsets information - switch { - case enabledToolsets == nil: - // nil means "use defaults" - sessionInfo["enabledToolsets"] = instructionToolsets - sessionInfo["toolsetsMode"] = "default" - case len(enabledToolsets) == 0: - sessionInfo["enabledToolsets"] = []string{} - sessionInfo["toolsetsMode"] = "none" - default: - sessionInfo["enabledToolsets"] = enabledToolsets - sessionInfo["toolsetsMode"] = "explicit" - } - - // Add enabled tools if specified - if len(cfg.EnabledTools) > 0 { - sessionInfo["enabledTools"] = cfg.EnabledTools - } - - // Try to fetch user information (get_me equivalent) - // If it fails, we simply omit it from the session info - if user, _, err := restClient.Users.Get(ctx, ""); err == nil && user != nil { - userInfo := map[string]any{ - "login": user.GetLogin(), - "id": user.GetID(), - "profileURL": user.GetHTMLURL(), - "avatarURL": user.GetAvatarURL(), - } - - // Add optional fields if they exist - if name := user.GetName(); name != "" { - userInfo["name"] = name - } - if email := user.GetEmail(); email != "" { - userInfo["email"] = email - } - if bio := user.GetBio(); bio != "" { - userInfo["bio"] = bio - } - if company := user.GetCompany(); company != "" { - userInfo["company"] = company - } - if location := user.GetLocation(); location != "" { - userInfo["location"] = location - } - - sessionInfo["user"] = userInfo - } - - // Set the metadata on the InitializeResult - if initResult.Meta == nil { - initResult.Meta = make(mcp.Meta) - } - initResult.Meta["sessionInfo"] = sessionInfo - - return initResult, nil - } - } -} - -// addUnauthenticatedSessionInfoMiddleware enriches the InitializeResult with session information -// for unauthenticated servers. This shows configuration but no user info until authenticated. -func addUnauthenticatedSessionInfoMiddleware(cfg MCPServerConfig, enabledToolsets []string, instructionToolsets []string) func(next mcp.MethodHandler) mcp.MethodHandler { - return func(next mcp.MethodHandler) mcp.MethodHandler { - return func(ctx context.Context, method string, request mcp.Request) (result mcp.Result, err error) { - // Only intercept initialize method - if method != "initialize" { - return next(ctx, method, request) - } - - // Call the next handler to get the InitializeResult - result, err = next(ctx, method, request) - if err != nil { - return result, err - } - - // Cast to InitializeResult to add metadata - initResult, ok := result.(*mcp.InitializeResult) - if !ok { - // If we can't cast, just return the original result - return result, err - } - - // Build session info metadata (without user info for unauthenticated mode) - sessionInfo := make(map[string]any) - - // Add configuration information - sessionInfo["readOnlyMode"] = cfg.ReadOnly - sessionInfo["lockdownMode"] = cfg.LockdownMode - sessionInfo["dynamicToolsets"] = cfg.DynamicToolsets - sessionInfo["authenticated"] = false - - // Add toolsets information - switch { - case enabledToolsets == nil: - // nil means "use defaults" - sessionInfo["enabledToolsets"] = instructionToolsets - sessionInfo["toolsetsMode"] = "default" - case len(enabledToolsets) == 0: - sessionInfo["enabledToolsets"] = []string{} - sessionInfo["toolsetsMode"] = "none" - default: - sessionInfo["enabledToolsets"] = enabledToolsets - sessionInfo["toolsetsMode"] = "explicit" - } - - // Add enabled tools if specified - if len(cfg.EnabledTools) > 0 { - sessionInfo["enabledTools"] = cfg.EnabledTools - } - - // Set the metadata on the InitializeResult - if initResult.Meta == nil { - initResult.Meta = make(mcp.Meta) - } - initResult.Meta["sessionInfo"] = sessionInfo - - return initResult, nil - } - } -} diff --git a/internal/ghmcp/server_test.go b/internal/ghmcp/server_test.go index 5e78f425c..04c0989d4 100644 --- a/internal/ghmcp/server_test.go +++ b/internal/ghmcp/server_test.go @@ -1,14 +1,9 @@ package ghmcp import ( - "context" - "net/http" "testing" "github.com/github/github-mcp-server/pkg/translations" - gogithub "github.com/google/go-github/v79/github" - "github.com/migueleliasweb/go-github-mock/src/mock" - "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -46,300 +41,6 @@ func TestNewMCPServer_CreatesSuccessfully(t *testing.T) { // is already tested in pkg/github/*_test.go. } -// TestSessionInfoMiddleware_AddsMetadataToInitializeResult verifies that the -// session info middleware enriches the InitializeResult with session metadata. -func TestSessionInfoMiddleware_AddsMetadataToInitializeResult(t *testing.T) { - t.Parallel() - - // Create test cases for different configurations - tests := []struct { - name string - cfg MCPServerConfig - enabledToolsets []string - instructionToolsets []string - expectedReadOnly bool - expectedLockdown bool - expectedDynamic bool - expectedToolsetsMode string - expectedToolsetsCount int - expectedTools []string - }{ - { - name: "default configuration", - cfg: MCPServerConfig{ - ReadOnly: false, - LockdownMode: false, - EnabledTools: nil, - EnabledToolsets: nil, - }, - enabledToolsets: nil, - instructionToolsets: []string{"default"}, - expectedReadOnly: false, - expectedLockdown: false, - expectedDynamic: false, - expectedToolsetsMode: "default", - expectedToolsetsCount: 1, - expectedTools: nil, - }, - { - name: "read-only with lockdown", - cfg: MCPServerConfig{ - ReadOnly: true, - LockdownMode: true, - EnabledTools: []string{"get_me", "list_repos"}, - EnabledToolsets: []string{"repos", "issues"}, - }, - enabledToolsets: []string{"repos", "issues"}, - instructionToolsets: []string{"repos", "issues"}, - expectedReadOnly: true, - expectedLockdown: true, - expectedDynamic: false, - expectedToolsetsMode: "explicit", - expectedToolsetsCount: 2, - expectedTools: []string{"get_me", "list_repos"}, - }, - { - name: "dynamic toolsets mode", - cfg: MCPServerConfig{ - DynamicToolsets: true, - ReadOnly: false, - LockdownMode: false, - EnabledToolsets: []string{}, - }, - enabledToolsets: []string{}, - instructionToolsets: []string{}, - expectedReadOnly: false, - expectedLockdown: false, - expectedDynamic: true, - expectedToolsetsMode: "none", - expectedToolsetsCount: 0, - expectedTools: nil, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - // Create a new mock client for each test case - mockClient := mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetUser, - gogithub.User{ - Login: gogithub.Ptr("testuser"), - ID: gogithub.Ptr(int64(12345)), - HTMLURL: gogithub.Ptr("https://github.com/testuser"), - AvatarURL: gogithub.Ptr("https://avatars.githubusercontent.com/u/12345"), - Name: gogithub.Ptr("Test User"), - Email: gogithub.Ptr("test@example.com"), - Bio: gogithub.Ptr("Test bio"), - Company: gogithub.Ptr("Test Company"), - Location: gogithub.Ptr("Test Location"), - }, - ), - ) - - // Create a GitHub client with the mock - restClient := gogithub.NewClient(mockClient).WithAuthToken("test-token") - // Create middleware - middleware := addSessionInfoMiddleware(tc.cfg, restClient, tc.enabledToolsets, tc.instructionToolsets) - - // Create a mock handler that returns a valid InitializeResult - mockHandler := func(_ context.Context, _ string, _ mcp.Request) (mcp.Result, error) { - return &mcp.InitializeResult{ - ProtocolVersion: "2024-11-05", - Capabilities: &mcp.ServerCapabilities{}, - ServerInfo: &mcp.Implementation{ - Name: "test-server", - Version: "1.0.0", - }, - }, nil - } - - // Wrap with middleware - handler := middleware(mockHandler) - - // Call with initialize method - result, err := handler(context.Background(), "initialize", &mcp.InitializeRequest{}) - require.NoError(t, err) - require.NotNil(t, result) - - // Cast to InitializeResult - initResult, ok := result.(*mcp.InitializeResult) - require.True(t, ok, "result should be InitializeResult") - - // Verify metadata exists - require.NotNil(t, initResult.Meta) - sessionInfo, exists := initResult.Meta["sessionInfo"] - require.True(t, exists, "sessionInfo should exist in metadata") - - // Cast sessionInfo to map - sessionInfoMap, ok := sessionInfo.(map[string]any) - require.True(t, ok, "sessionInfo should be a map") - - // Verify configuration flags - assert.Equal(t, tc.expectedReadOnly, sessionInfoMap["readOnlyMode"]) - assert.Equal(t, tc.expectedLockdown, sessionInfoMap["lockdownMode"]) - assert.Equal(t, tc.expectedDynamic, sessionInfoMap["dynamicToolsets"]) - assert.Equal(t, tc.expectedToolsetsMode, sessionInfoMap["toolsetsMode"]) - - // Verify toolsets - enabledToolsets, ok := sessionInfoMap["enabledToolsets"].([]string) - require.True(t, ok, "enabledToolsets should be a string slice") - assert.Len(t, enabledToolsets, tc.expectedToolsetsCount) - - // Verify enabled tools if specified - if tc.expectedTools != nil { - tools, ok := sessionInfoMap["enabledTools"].([]string) - require.True(t, ok, "enabledTools should be a string slice") - assert.Equal(t, tc.expectedTools, tools) - } - - // Verify user info exists (since we mocked a successful API call) - userInfo, exists := sessionInfoMap["user"] - require.True(t, exists, "user info should exist") - userInfoMap, ok := userInfo.(map[string]any) - require.True(t, ok, "user info should be a map") - assert.Equal(t, "testuser", userInfoMap["login"]) - assert.Equal(t, int64(12345), userInfoMap["id"]) - assert.Equal(t, "https://github.com/testuser", userInfoMap["profileURL"]) - assert.Equal(t, "Test User", userInfoMap["name"]) - assert.Equal(t, "test@example.com", userInfoMap["email"]) - }) - } -} - -// TestSessionInfoMiddleware_OmitsUserOnAPIFailure verifies that when the -// get_me API call fails, the user info is omitted from session info. -func TestSessionInfoMiddleware_OmitsUserOnAPIFailure(t *testing.T) { - t.Parallel() - - // Mock GitHub API to return an error - mockClient := mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetUser, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusUnauthorized) - }), - ), - ) - - // Create a GitHub client with the mock - restClient := gogithub.NewClient(mockClient).WithAuthToken("invalid-token") - - cfg := MCPServerConfig{ - ReadOnly: false, - LockdownMode: false, - } - - // Create middleware - middleware := addSessionInfoMiddleware(cfg, restClient, []string{"context"}, []string{"context"}) - - // Create a mock handler - mockHandler := func(_ context.Context, _ string, _ mcp.Request) (mcp.Result, error) { - return &mcp.InitializeResult{ - ProtocolVersion: "2024-11-05", - Capabilities: &mcp.ServerCapabilities{}, - ServerInfo: &mcp.Implementation{ - Name: "test-server", - Version: "1.0.0", - }, - }, nil - } - - // Wrap with middleware - handler := middleware(mockHandler) - - // Call with initialize method - result, err := handler(context.Background(), "initialize", &mcp.InitializeRequest{}) - require.NoError(t, err) - require.NotNil(t, result) - - // Cast to InitializeResult - initResult, ok := result.(*mcp.InitializeResult) - require.True(t, ok) - - // Verify metadata exists - require.NotNil(t, initResult.Meta) - sessionInfo, exists := initResult.Meta["sessionInfo"] - require.True(t, exists) - - // Cast sessionInfo to map - sessionInfoMap, ok := sessionInfo.(map[string]any) - require.True(t, ok) - - // Verify user info does NOT exist (API call failed) - _, exists = sessionInfoMap["user"] - assert.False(t, exists, "user info should not exist when API call fails") - - // Verify other fields still exist - assert.NotNil(t, sessionInfoMap["readOnlyMode"]) - assert.NotNil(t, sessionInfoMap["lockdownMode"]) - assert.NotNil(t, sessionInfoMap["enabledToolsets"]) -} - -// TestUnauthenticatedSessionInfoMiddleware verifies that the unauthenticated -// middleware adds session info without user data. -func TestUnauthenticatedSessionInfoMiddleware(t *testing.T) { - t.Parallel() - - cfg := MCPServerConfig{ - ReadOnly: true, - LockdownMode: false, - DynamicToolsets: true, - EnabledTools: []string{"tool1", "tool2"}, - } - - // Create middleware - middleware := addUnauthenticatedSessionInfoMiddleware(cfg, []string{}, []string{}) - - // Create a mock handler - mockHandler := func(_ context.Context, _ string, _ mcp.Request) (mcp.Result, error) { - return &mcp.InitializeResult{ - ProtocolVersion: "2024-11-05", - Capabilities: &mcp.ServerCapabilities{}, - ServerInfo: &mcp.Implementation{ - Name: "test-server", - Version: "1.0.0", - }, - }, nil - } - - // Wrap with middleware - handler := middleware(mockHandler) - - // Call with initialize method - result, err := handler(context.Background(), "initialize", &mcp.InitializeRequest{}) - require.NoError(t, err) - require.NotNil(t, result) - - // Cast to InitializeResult - initResult, ok := result.(*mcp.InitializeResult) - require.True(t, ok) - - // Verify metadata exists - require.NotNil(t, initResult.Meta) - sessionInfo, exists := initResult.Meta["sessionInfo"] - require.True(t, exists) - - // Cast sessionInfo to map - sessionInfoMap, ok := sessionInfo.(map[string]any) - require.True(t, ok) - - // Verify configuration flags - assert.Equal(t, true, sessionInfoMap["readOnlyMode"]) - assert.Equal(t, false, sessionInfoMap["lockdownMode"]) - assert.Equal(t, true, sessionInfoMap["dynamicToolsets"]) - assert.Equal(t, false, sessionInfoMap["authenticated"]) - - // Verify enabled tools - tools, ok := sessionInfoMap["enabledTools"].([]string) - require.True(t, ok) - assert.Equal(t, []string{"tool1", "tool2"}, tools) - - // Verify user info does NOT exist (unauthenticated mode) - _, exists = sessionInfoMap["user"] - assert.False(t, exists, "user info should not exist in unauthenticated mode") -} - // TestResolveEnabledToolsets verifies the toolset resolution logic. func TestResolveEnabledToolsets(t *testing.T) { t.Parallel() diff --git a/pkg/github/auth_tools.go b/pkg/github/auth_tools.go index d5fd115be..24e9ed94b 100644 --- a/pkg/github/auth_tools.go +++ b/pkg/github/auth_tools.go @@ -34,6 +34,9 @@ type AuthToolDependencies struct { // OnAuthComplete is called after authentication flow completes (success or failure). // It can be used to clean up auth tools after they're no longer needed. OnAuthComplete func() + // GetSessionInfo is called after authentication to get session context information + // to include in the auth_login success response. + GetSessionInfo func(ctx context.Context, token string) string } // AuthTools returns the authentication tools. @@ -164,11 +167,16 @@ func pollAndComplete(ctx context.Context, session *mcp.ServerSession, authDeps A authDeps.OnAuthComplete() } - return utils.NewToolResultText(`✅ Successfully authenticated with GitHub! + // Build the success response with session information + successMessage := "✅ Successfully authenticated with GitHub!\n\nAll GitHub tools are now available." -All GitHub tools are now available. You can now: -- Create and manage repositories -- Work with issues and pull requests -- Access your organizations and teams -- And much more, depending on your GitHub configuration`), nil + // Get session info if the callback is provided + if authDeps.GetSessionInfo != nil { + sessionInfo := authDeps.GetSessionInfo(ctx, authMgr.Token()) + if sessionInfo != "" { + successMessage += "\n\n" + sessionInfo + } + } + + return utils.NewToolResultText(successMessage), nil }