diff --git a/README.md b/README.md index 86e516c..b92c111 100644 --- a/README.md +++ b/README.md @@ -16,9 +16,7 @@ automation and interaction capabilities for developers and tools. 1. To run the server in a container, you will need to have [Docker](https://www.docker.com/) installed. 2. [Create a GitHub Personal Access Token](https://github.com/settings/personal-access-tokens/new). -The MCP server can use many of the GitHub APIs, so enable the permissions that you feel comfortable granting your AI tools (to learn more about access tokens, please check out the [documentation](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens)). - - + The MCP server can use many of the GitHub APIs, so enable the permissions that you feel comfortable granting your AI tools (to learn more about access tokens, please check out the [documentation](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens)). ## Installation @@ -95,6 +93,75 @@ If you don't have Docker, you can use `go` to build the binary in the command with the `GITHUB_PERSONAL_ACCESS_TOKEN` environment variable set to your token. +## Features Configuration + +The GitHub MCP Server supports enabling or disabling specific groups of functionalities via the `--features` flag. This allows you to control which GitHub API capabilities are available to your AI tools. + +### Available Features + +The following feature groups are available: + +| Feature | Description | Default Status | +| --------------- | ------------------------------------------------------------- | -------------- | +| `repos` | Repository-related tools (file operations, branches, commits) | Enabled | +| `issues` | Issue-related tools (create, read, update, comment) | Enabled | +| `search` | Search functionality (code, repositories, users) | Enabled | +| `pull_requests` | Pull request operations (create, merge, review) | Enabled | +| `code_security` | Code scanning alerts and security features | Disabled | +| `experiments` | Experimental features (not considered stable) | Disabled | +| `everything` | Special flag to enable all features | Disabled | + +### Specifying Features + +You can enable specific features in two ways: + +1. **Using Command Line Argument**: + + ```bash + github-mcp-server --features repos,issues,pull_requests,code_security + ``` + +2. **Using Environment Variable**: + ```bash + GITHUB_FEATURES="repos,issues,pull_requests,code_security" ./github-mcp-server + ``` + +The environment variable `GITHUB_FEATURES` takes precedence over the command line argument if both are provided. + +### Default Enabled Features + +By default, the following features are enabled: + +- `repos` +- `issues` +- `pull_requests` +- `search` + +### Using With Docker + +When using Docker, you can pass the features as environment variables: + +```bash +docker run -i --rm \ + -e GITHUB_PERSONAL_ACCESS_TOKEN= \ + -e GITHUB_FEATURES="repos,issues,pull_requests,code_security,experiments" \ + ghcr.io/github/github-mcp-server +``` + +### The "everything" Feature + +The special feature `everything` can be provided to enable all available features regardless of their individual settings: + +```bash +./github-mcp-server --features everything +``` + +Or using the environment variable: + +```bash +GITHUB_FEATURES="everything" ./github-mcp-server +``` + ## GitHub Enterprise Server The flag `--gh-host` and the environment variable `GH_HOST` can be used to set diff --git a/cmd/github-mcp-server/main.go b/cmd/github-mcp-server/main.go index dd4d41a..01780ba 100644 --- a/cmd/github-mcp-server/main.go +++ b/cmd/github-mcp-server/main.go @@ -9,8 +9,10 @@ import ( stdlog "log" "os" "os/signal" + "strings" "syscall" + "github.com/github/github-mcp-server/pkg/features" "github.com/github/github-mcp-server/pkg/github" iolog "github.com/github/github-mcp-server/pkg/log" "github.com/github/github-mcp-server/pkg/translations" @@ -46,6 +48,12 @@ var ( if err != nil { stdlog.Fatal("Failed to initialize logger:", err) } + enabledFeatures := viper.GetStringSlice("features") + features, err := initFeatures(enabledFeatures) + if err != nil { + stdlog.Fatal("Failed to initialize features:", err) + } + logCommands := viper.GetBool("enable-command-logging") cfg := runConfig{ readOnly: readOnly, @@ -53,6 +61,7 @@ var ( logCommands: logCommands, exportTranslations: exportTranslations, prettyPrintJSON: prettyPrintJSON, + features: features, } if err := runStdioServer(cfg); err != nil { stdlog.Fatal("failed to run stdio server:", err) @@ -61,10 +70,45 @@ var ( } ) +func initFeatures(passedFeatures []string) (*features.FeatureSet, error) { + // Create a new feature set + fs := features.NewFeatureSet() + + // Define all available features with their default state (disabled) + fs.AddFeature("repos", "Repository related tools", false) + fs.AddFeature("issues", "Issues related tools", false) + fs.AddFeature("search", "Search related tools", false) + fs.AddFeature("pull_requests", "Pull request related tools", false) + fs.AddFeature("code_security", "Code security related tools", false) + fs.AddFeature("experiments", "Experimental features that are not considered stable yet", false) + + // fs.AddFeature("actions", "GitHub Actions related tools", false) + // fs.AddFeature("projects", "GitHub Projects related tools", false) + // fs.AddFeature("secret_protection", "Secret protection related tools", false) + // fs.AddFeature("gists", "Gist related tools", false) + + // Env gets precedence over command line flags + if envFeats := os.Getenv("GITHUB_FEATURES"); envFeats != "" { + passedFeatures = []string{} + // Split envFeats by comma, trim whitespace, and add to the slice + for _, feature := range strings.Split(envFeats, ",") { + passedFeatures = append(passedFeatures, strings.TrimSpace(feature)) + } + } + + // Enable the requested features + if err := fs.EnableFeatures(passedFeatures); err != nil { + return nil, err + } + + return fs, nil +} + func init() { cobra.OnInitialize(initConfig) // Add global flags that will be shared by all commands + rootCmd.PersistentFlags().StringSlice("features", []string{"repos", "issues", "pull_requests", "search"}, "A comma separated list of groups of tools to enable, defaults to issues/repos/search") rootCmd.PersistentFlags().Bool("read-only", false, "Restrict the server to read-only operations") rootCmd.PersistentFlags().String("log-file", "", "Path to log file") rootCmd.PersistentFlags().Bool("enable-command-logging", false, "When enabled, the server will log all command requests and responses to the log file") @@ -73,6 +117,7 @@ func init() { rootCmd.PersistentFlags().Bool("pretty-print-json", false, "Pretty print JSON output") // Bind flag to viper + _ = viper.BindPFlag("features", rootCmd.PersistentFlags().Lookup("features")) _ = viper.BindPFlag("read-only", rootCmd.PersistentFlags().Lookup("read-only")) _ = viper.BindPFlag("log-file", rootCmd.PersistentFlags().Lookup("log-file")) _ = viper.BindPFlag("enable-command-logging", rootCmd.PersistentFlags().Lookup("enable-command-logging")) @@ -113,6 +158,7 @@ type runConfig struct { logCommands bool exportTranslations bool prettyPrintJSON bool + features *features.FeatureSet } // JSONPrettyPrintWriter is a Writer that pretty prints input to indented JSON @@ -158,7 +204,7 @@ func runStdioServer(cfg runConfig) error { t, dumpTranslations := translations.TranslationHelper() // Create - ghServer := github.NewServer(ghClient, version, cfg.readOnly, t) + ghServer := github.NewServer(ghClient, cfg.features, version, cfg.readOnly, t) stdioServer := server.NewStdioServer(ghServer) stdLogger := stdlog.New(cfg.logger.Writer(), "stdioserver", 0) diff --git a/pkg/features/features.go b/pkg/features/features.go new file mode 100644 index 0000000..7db0080 --- /dev/null +++ b/pkg/features/features.go @@ -0,0 +1,68 @@ +package features + +import "fmt" + +type Feature struct { + Name string + Description string + Enabled bool +} + +type FeatureSet struct { + Features map[string]Feature + everythingOn bool +} + +func NewFeatureSet() *FeatureSet { + return &FeatureSet{ + Features: make(map[string]Feature), + everythingOn: false, + } +} + +func (fs *FeatureSet) AddFeature(name string, description string, enabled bool) { + fs.Features[name] = Feature{ + Name: name, + Description: description, + Enabled: enabled, + } +} + +func (fs *FeatureSet) IsEnabled(name string) bool { + // If everythingOn is true, all features are enabled + if fs.everythingOn { + return true + } + + feature, exists := fs.Features[name] + if !exists { + return false + } + return feature.Enabled +} + +func (fs *FeatureSet) EnableFeatures(names []string) error { + for _, name := range names { + err := fs.EnableFeature(name) + if err != nil { + return err + } + } + return nil +} + +func (fs *FeatureSet) EnableFeature(name string) error { + // Special case for "everything" + if name == "everything" { + fs.everythingOn = true + return nil + } + + feature, exists := fs.Features[name] + if !exists { + return fmt.Errorf("feature %s does not exist", name) + } + feature.Enabled = true + fs.Features[name] = feature + return nil +} diff --git a/pkg/features/features_test.go b/pkg/features/features_test.go new file mode 100644 index 0000000..cceb4c2 --- /dev/null +++ b/pkg/features/features_test.go @@ -0,0 +1,219 @@ +package features + +import ( + "testing" +) + +func TestNewFeatureSet(t *testing.T) { + fs := NewFeatureSet() + if fs == nil { + t.Fatal("Expected NewFeatureSet to return a non-nil pointer") + } + if fs.Features == nil { + t.Fatal("Expected Features map to be initialized") + } + if len(fs.Features) != 0 { + t.Fatalf("Expected Features map to be empty, got %d items", len(fs.Features)) + } + if fs.everythingOn { + t.Fatal("Expected everythingOn to be initialized as false") + } +} + +func TestAddFeature(t *testing.T) { + fs := NewFeatureSet() + + // Test adding a feature + fs.AddFeature("test-feature", "A test feature", true) + + // Verify feature was added correctly + if len(fs.Features) != 1 { + t.Errorf("Expected 1 feature, got %d", len(fs.Features)) + } + + feature, exists := fs.Features["test-feature"] + if !exists { + t.Fatal("Feature was not added to the map") + } + + if feature.Name != "test-feature" { + t.Errorf("Expected feature name to be 'test-feature', got '%s'", feature.Name) + } + + if feature.Description != "A test feature" { + t.Errorf("Expected feature description to be 'A test feature', got '%s'", feature.Description) + } + + if !feature.Enabled { + t.Error("Expected feature to be enabled") + } + + // Test adding another feature + fs.AddFeature("another-feature", "Another test feature", false) + + if len(fs.Features) != 2 { + t.Errorf("Expected 2 features, got %d", len(fs.Features)) + } + + // Test overriding existing feature + fs.AddFeature("test-feature", "Updated description", false) + + feature = fs.Features["test-feature"] + if feature.Description != "Updated description" { + t.Errorf("Expected feature description to be updated to 'Updated description', got '%s'", feature.Description) + } + + if feature.Enabled { + t.Error("Expected feature to be disabled after update") + } +} + +func TestIsEnabled(t *testing.T) { + fs := NewFeatureSet() + + // Test with non-existent feature + if fs.IsEnabled("non-existent") { + t.Error("Expected IsEnabled to return false for non-existent feature") + } + + // Test with disabled feature + fs.AddFeature("disabled-feature", "A disabled feature", false) + if fs.IsEnabled("disabled-feature") { + t.Error("Expected IsEnabled to return false for disabled feature") + } + + // Test with enabled feature + fs.AddFeature("enabled-feature", "An enabled feature", true) + if !fs.IsEnabled("enabled-feature") { + t.Error("Expected IsEnabled to return true for enabled feature") + } +} + +func TestEnableFeature(t *testing.T) { + fs := NewFeatureSet() + + // Test enabling non-existent feature + err := fs.EnableFeature("non-existent") + if err == nil { + t.Error("Expected error when enabling non-existent feature") + } + + // Test enabling feature + fs.AddFeature("test-feature", "A test feature", false) + + if fs.IsEnabled("test-feature") { + t.Error("Expected feature to be disabled initially") + } + + err = fs.EnableFeature("test-feature") + if err != nil { + t.Errorf("Expected no error when enabling feature, got: %v", err) + } + + if !fs.IsEnabled("test-feature") { + t.Error("Expected feature to be enabled after EnableFeature call") + } + + // Test enabling already enabled feature + err = fs.EnableFeature("test-feature") + if err != nil { + t.Errorf("Expected no error when enabling already enabled feature, got: %v", err) + } +} + +func TestEnableFeatures(t *testing.T) { + fs := NewFeatureSet() + + // Prepare features + fs.AddFeature("feature1", "Feature 1", false) + fs.AddFeature("feature2", "Feature 2", false) + + // Test enabling multiple features + err := fs.EnableFeatures([]string{"feature1", "feature2"}) + if err != nil { + t.Errorf("Expected no error when enabling features, got: %v", err) + } + + if !fs.IsEnabled("feature1") { + t.Error("Expected feature1 to be enabled") + } + + if !fs.IsEnabled("feature2") { + t.Error("Expected feature2 to be enabled") + } + + // Test with non-existent feature in the list + err = fs.EnableFeatures([]string{"feature1", "non-existent"}) + if err == nil { + t.Error("Expected error when enabling list with non-existent feature") + } + + // Test with empty list + err = fs.EnableFeatures([]string{}) + if err != nil { + t.Errorf("Expected no error with empty feature list, got: %v", err) + } + + // Test enabling everything through EnableFeatures + fs = NewFeatureSet() + err = fs.EnableFeatures([]string{"everything"}) + if err != nil { + t.Errorf("Expected no error when enabling 'everything', got: %v", err) + } + + if !fs.everythingOn { + t.Error("Expected everythingOn to be true after enabling 'everything' via EnableFeatures") + } +} + +func TestEnableEverything(t *testing.T) { + fs := NewFeatureSet() + + // Add a disabled feature + fs.AddFeature("test-feature", "A test feature", false) + + // Verify it's disabled + if fs.IsEnabled("test-feature") { + t.Error("Expected feature to be disabled initially") + } + + // Enable "everything" + err := fs.EnableFeature("everything") + if err != nil { + t.Errorf("Expected no error when enabling 'everything', got: %v", err) + } + + // Verify everythingOn was set + if !fs.everythingOn { + t.Error("Expected everythingOn to be true after enabling 'everything'") + } + + // Verify the previously disabled feature is now enabled + if !fs.IsEnabled("test-feature") { + t.Error("Expected feature to be enabled when everythingOn is true") + } + + // Verify a non-existent feature is also enabled + if !fs.IsEnabled("non-existent") { + t.Error("Expected non-existent feature to be enabled when everythingOn is true") + } +} + +func TestIsEnabledWithEverythingOn(t *testing.T) { + fs := NewFeatureSet() + + // Enable "everything" + err := fs.EnableFeature("everything") + if err != nil { + t.Errorf("Expected no error when enabling 'everything', got: %v", err) + } + + // Test that any feature name returns true with IsEnabled + if !fs.IsEnabled("some-feature") { + t.Error("Expected IsEnabled to return true for any feature when everythingOn is true") + } + + if !fs.IsEnabled("another-feature") { + t.Error("Expected IsEnabled to return true for any feature when everythingOn is true") + } +} diff --git a/pkg/github/server.go b/pkg/github/server.go index 5852d58..6c9e01a 100644 --- a/pkg/github/server.go +++ b/pkg/github/server.go @@ -8,6 +8,7 @@ import ( "io" "net/http" + "github.com/github/github-mcp-server/pkg/features" "github.com/github/github-mcp-server/pkg/translations" "github.com/google/go-github/v69/github" "github.com/mark3labs/mcp-go/mcp" @@ -15,7 +16,7 @@ import ( ) // NewServer creates a new GitHub MCP server with the specified GH client and logger. -func NewServer(client *github.Client, version string, readOnly bool, t translations.TranslationHelperFunc) *server.MCPServer { +func NewServer(client *github.Client, featureSet *features.FeatureSet, version string, readOnly bool, t translations.TranslationHelperFunc) *server.MCPServer { // Create a new MCP server s := server.NewMCPServer( "github-mcp-server", @@ -23,60 +24,75 @@ func NewServer(client *github.Client, version string, readOnly bool, t translati server.WithResourceCapabilities(true, true), server.WithLogging()) - // Add GitHub Resources - s.AddResourceTemplate(GetRepositoryResourceContent(client, t)) - s.AddResourceTemplate(GetRepositoryResourceBranchContent(client, t)) - s.AddResourceTemplate(GetRepositoryResourceCommitContent(client, t)) - s.AddResourceTemplate(GetRepositoryResourceTagContent(client, t)) - s.AddResourceTemplate(GetRepositoryResourcePrContent(client, t)) - - // Add GitHub tools - Issues - s.AddTool(GetIssue(client, t)) - s.AddTool(SearchIssues(client, t)) - s.AddTool(ListIssues(client, t)) - s.AddTool(GetIssueComments(client, t)) - if !readOnly { - s.AddTool(CreateIssue(client, t)) - s.AddTool(AddIssueComment(client, t)) - s.AddTool(UpdateIssue(client, t)) + // Add GitHub tools - Users + s.AddTool(GetMe(client, t)) // GetMe is always exposed and not part of configurable features + + if featureSet.IsEnabled("repos") { + // Add GitHub Repository Resources + s.AddResourceTemplate(GetRepositoryResourceContent(client, t)) + s.AddResourceTemplate(GetRepositoryResourceBranchContent(client, t)) + s.AddResourceTemplate(GetRepositoryResourceCommitContent(client, t)) + s.AddResourceTemplate(GetRepositoryResourceTagContent(client, t)) + s.AddResourceTemplate(GetRepositoryResourcePrContent(client, t)) + + // Add GitHub tools - Repositories + s.AddTool(SearchRepositories(client, t)) + s.AddTool(GetFileContents(client, t)) + s.AddTool(ListCommits(client, t)) + if !readOnly { + s.AddTool(CreateOrUpdateFile(client, t)) + s.AddTool(CreateRepository(client, t)) + s.AddTool(ForkRepository(client, t)) + s.AddTool(CreateBranch(client, t)) + s.AddTool(PushFiles(client, t)) + } + } + + if featureSet.IsEnabled("issues") { + // Add GitHub tools - Issues + s.AddTool(GetIssue(client, t)) + s.AddTool(SearchIssues(client, t)) + s.AddTool(ListIssues(client, t)) + s.AddTool(GetIssueComments(client, t)) + if !readOnly { + s.AddTool(CreateIssue(client, t)) + s.AddTool(AddIssueComment(client, t)) + s.AddTool(UpdateIssue(client, t)) + } } - // Add GitHub tools - Pull Requests - s.AddTool(GetPullRequest(client, t)) - s.AddTool(ListPullRequests(client, t)) - s.AddTool(GetPullRequestFiles(client, t)) - s.AddTool(GetPullRequestStatus(client, t)) - s.AddTool(GetPullRequestComments(client, t)) - s.AddTool(GetPullRequestReviews(client, t)) - if !readOnly { - s.AddTool(MergePullRequest(client, t)) - s.AddTool(UpdatePullRequestBranch(client, t)) - s.AddTool(CreatePullRequestReview(client, t)) - s.AddTool(CreatePullRequest(client, t)) + if featureSet.IsEnabled("pull_requests") { + // Add GitHub tools - Pull Requests + s.AddTool(GetPullRequest(client, t)) + s.AddTool(ListPullRequests(client, t)) + s.AddTool(GetPullRequestFiles(client, t)) + s.AddTool(GetPullRequestStatus(client, t)) + s.AddTool(GetPullRequestComments(client, t)) + s.AddTool(GetPullRequestReviews(client, t)) + if !readOnly { + s.AddTool(MergePullRequest(client, t)) + s.AddTool(UpdatePullRequestBranch(client, t)) + s.AddTool(CreatePullRequestReview(client, t)) + s.AddTool(CreatePullRequest(client, t)) + } } - // Add GitHub tools - Repositories - s.AddTool(SearchRepositories(client, t)) - s.AddTool(GetFileContents(client, t)) - s.AddTool(ListCommits(client, t)) - if !readOnly { - s.AddTool(CreateOrUpdateFile(client, t)) - s.AddTool(CreateRepository(client, t)) - s.AddTool(ForkRepository(client, t)) - s.AddTool(CreateBranch(client, t)) - s.AddTool(PushFiles(client, t)) + if featureSet.IsEnabled("search") { + // Add GitHub tools - Search + s.AddTool(SearchCode(client, t)) + s.AddTool(SearchUsers(client, t)) } - // Add GitHub tools - Search - s.AddTool(SearchCode(client, t)) - s.AddTool(SearchUsers(client, t)) + if featureSet.IsEnabled("code_security") { + // Add GitHub tools - Code Scanning + s.AddTool(GetCodeScanningAlert(client, t)) + s.AddTool(ListCodeScanningAlerts(client, t)) + } - // Add GitHub tools - Users - s.AddTool(GetMe(client, t)) + if featureSet.IsEnabled("experiments") { + s.AddTool(ListAvailableFeatures(featureSet, t)) + } - // Add GitHub tools - Code Scanning - s.AddTool(GetCodeScanningAlert(client, t)) - s.AddTool(ListCodeScanningAlerts(client, t)) return s } @@ -112,6 +128,26 @@ func GetMe(client *github.Client, t translations.TranslationHelperFunc) (tool mc } } +func ListAvailableFeatures(featureSet *features.FeatureSet, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("list_available_features", + mcp.WithDescription(t("TOOL_LIST_AVAILABLE_FEATURES_DESCRIPTION", "List all available features this MCP server can offer, providing the enabled status of each.")), + ), + func(_ context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) { + // We need to convert the FeatureSet back to a map for JSON serialization + featureMap := make(map[string]bool) + for name := range featureSet.Features { + featureMap[name] = featureSet.IsEnabled(name) + } + + r, err := json.Marshal(featureMap) + if err != nil { + return nil, fmt.Errorf("failed to marshal features: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + // isAcceptedError checks if the error is an accepted error. func isAcceptedError(err error) bool { var acceptedError *github.AcceptedError diff --git a/pkg/github/server_test.go b/pkg/github/server_test.go index 979046f..90b66bf 100644 --- a/pkg/github/server_test.go +++ b/pkg/github/server_test.go @@ -8,6 +8,7 @@ import ( "testing" "time" + "github.com/github/github-mcp-server/pkg/features" "github.com/github/github-mcp-server/pkg/translations" "github.com/google/go-github/v69/github" "github.com/migueleliasweb/go-github-mock/src/mock" @@ -634,3 +635,102 @@ func TestOptionalPaginationParams(t *testing.T) { }) } } + +func Test_ListAvailableFeatures(t *testing.T) { + // Verify tool definition + featureSet := features.NewFeatureSet() + + // Add some features with different states + featureSet.AddFeature("feature1", "Test feature 1", true) + featureSet.AddFeature("feature2", "Test feature 2", false) + featureSet.AddFeature("feature3", "Test feature 3", true) + + // Test ListAvailableFeatures tool definition + tool, _ := ListAvailableFeatures(featureSet, translations.NullTranslationHelper) + assert.Equal(t, "list_available_features", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Empty(t, tool.InputSchema.Required) // No required parameters + + tests := []struct { + name string + featureSet *features.FeatureSet + requestArgs map[string]interface{} + expectError bool + expectedErrMsg string + expectedEnabled map[string]bool + }{ + { + name: "regular feature set", + featureSet: func() *features.FeatureSet { + fs := features.NewFeatureSet() + fs.AddFeature("feature1", "Test feature 1", true) + fs.AddFeature("feature2", "Test feature 2", false) + fs.AddFeature("feature3", "Test feature 3", true) + return fs + }(), + requestArgs: map[string]interface{}{}, + expectError: false, + expectedEnabled: map[string]bool{ + "feature1": true, + "feature2": false, + "feature3": true, + }, + }, + { + name: "empty feature set", + featureSet: features.NewFeatureSet(), + requestArgs: map[string]interface{}{}, + expectError: false, + expectedEnabled: map[string]bool{}, + }, + { + name: "feature set with everything enabled", + featureSet: func() *features.FeatureSet { + fs := features.NewFeatureSet() + fs.AddFeature("feature1", "Test feature 1", false) + fs.AddFeature("feature2", "Test feature 2", false) + _ = fs.EnableFeature("everything") + return fs + }(), + requestArgs: map[string]interface{}{}, + expectError: false, + expectedEnabled: map[string]bool{ + "feature1": true, + "feature2": true, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Get tool handler + _, handler := ListAvailableFeatures(tc.featureSet, translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + // Verify results + if tc.expectError { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.expectedErrMsg) + return + } + + require.NoError(t, err) + + // Parse result and get text content + textContent := getTextResult(t, result) + + // Unmarshal the result to verify features + var returnedFeatures map[string]bool + err = json.Unmarshal([]byte(textContent.Text), &returnedFeatures) + require.NoError(t, err) + + // Verify the features match what we expect + assert.Equal(t, tc.expectedEnabled, returnedFeatures) + }) + } +}