diff --git a/README.md b/README.md index 352bb50e..ea8a821a 100644 --- a/README.md +++ b/README.md @@ -219,6 +219,41 @@ docker run -i --rm \ ghcr.io/github/github-mcp-server ``` +## Content Filtering + +The GitHub MCP Server includes a content filtering feature that removes invisible characters and hidden content from GitHub issues, PRs, and comments. This helps prevent potential security risks and ensures better readability of content. + +### What Gets Filtered + +- **Invisible Unicode Characters**: Zero-width spaces, zero-width joiners, zero-width non-joiners, bidirectional marks, and other invisible Unicode characters +- **HTML Comments**: Comments that might contain hidden information +- **Hidden HTML Elements**: Script, style, iframe, and other potentially dangerous HTML elements +- **Collapsed Sections**: Details/summary elements that might hide content +- **Very Small Text**: Content with extremely small font size + +### Controlling Content Filtering + +Content filtering is enabled by default. You can disable it using the `--disable-content-filtering` flag: + +```bash +github-mcp-server --disable-content-filtering +``` + +Or using the environment variable: + +```bash +GITHUB_DISABLE_CONTENT_FILTERING=1 github-mcp-server +``` + +When using Docker, you can set the environment variable: + +```bash +docker run -i --rm \ + -e GITHUB_PERSONAL_ACCESS_TOKEN= \ + -e GITHUB_DISABLE_CONTENT_FILTERING=1 \ + ghcr.io/github/github-mcp-server +``` + ## GitHub Enterprise Server The flag `--gh-host` and the environment variable `GITHUB_HOST` can be used to set diff --git a/cmd/github-mcp-server/main.go b/cmd/github-mcp-server/main.go index fb716f78..bee5e6b0 100644 --- a/cmd/github-mcp-server/main.go +++ b/cmd/github-mcp-server/main.go @@ -44,15 +44,16 @@ var ( } stdioServerConfig := ghmcp.StdioServerConfig{ - Version: version, - Host: viper.GetString("host"), - Token: token, - EnabledToolsets: enabledToolsets, - DynamicToolsets: viper.GetBool("dynamic_toolsets"), - ReadOnly: viper.GetBool("read-only"), - ExportTranslations: viper.GetBool("export-translations"), - EnableCommandLogging: viper.GetBool("enable-command-logging"), - LogFilePath: viper.GetString("log-file"), + Version: version, + Host: viper.GetString("host"), + Token: token, + EnabledToolsets: enabledToolsets, + DynamicToolsets: viper.GetBool("dynamic_toolsets"), + ReadOnly: viper.GetBool("read-only"), + DisableContentFiltering: viper.GetBool("disable-content-filtering"), + ExportTranslations: viper.GetBool("export-translations"), + EnableCommandLogging: viper.GetBool("enable-command-logging"), + LogFilePath: viper.GetString("log-file"), } return ghmcp.RunStdioServer(stdioServerConfig) @@ -73,6 +74,7 @@ func init() { rootCmd.PersistentFlags().Bool("enable-command-logging", false, "When enabled, the server will log all command requests and responses to the log file") rootCmd.PersistentFlags().Bool("export-translations", false, "Save translations to a JSON file") rootCmd.PersistentFlags().String("gh-host", "", "Specify the GitHub hostname (for GitHub Enterprise etc.)") + rootCmd.PersistentFlags().Bool("disable-content-filtering", false, "Disable filtering of invisible characters and hidden content from GitHub issues, PRs, and comments") // Bind flag to viper _ = viper.BindPFlag("toolsets", rootCmd.PersistentFlags().Lookup("toolsets")) @@ -82,6 +84,7 @@ func init() { _ = viper.BindPFlag("enable-command-logging", rootCmd.PersistentFlags().Lookup("enable-command-logging")) _ = viper.BindPFlag("export-translations", rootCmd.PersistentFlags().Lookup("export-translations")) _ = viper.BindPFlag("host", rootCmd.PersistentFlags().Lookup("gh-host")) + _ = viper.BindPFlag("disable-content-filtering", rootCmd.PersistentFlags().Lookup("disable-content-filtering")) // Add subcommands rootCmd.AddCommand(stdioCmd) diff --git a/internal/ghmcp/server.go b/internal/ghmcp/server.go index a75a9e0c..f5906da5 100644 --- a/internal/ghmcp/server.go +++ b/internal/ghmcp/server.go @@ -43,6 +43,9 @@ type MCPServerConfig struct { // ReadOnly indicates if we should only offer read-only tools ReadOnly bool + // DisableContentFiltering disables filtering of invisible characters and hidden content + DisableContentFiltering bool + // Translator provides translated text for the server tooling Translator translations.TranslationHelperFunc } @@ -91,7 +94,10 @@ func NewMCPServer(cfg MCPServerConfig) (*server.MCPServer, error) { OnBeforeInitialize: []server.OnBeforeInitializeFunc{beforeInit}, } - ghServer := github.NewServer(cfg.Version, server.WithHooks(hooks)) + ghServer := github.NewServerWithConfig(github.ServerConfig{ + Version: cfg.Version, + DisableContentFiltering: cfg.DisableContentFiltering, + }, server.WithHooks(hooks)) enabledToolsets := cfg.EnabledToolsets if cfg.DynamicToolsets { @@ -160,6 +166,9 @@ type StdioServerConfig struct { // ReadOnly indicates if we should only register read-only tools ReadOnly bool + // DisableContentFiltering disables filtering of invisible characters and hidden content + DisableContentFiltering bool + // ExportTranslations indicates if we should export translations // See: https://github.com/github/github-mcp-server?tab=readme-ov-file#i18n--overriding-descriptions ExportTranslations bool @@ -180,13 +189,14 @@ func RunStdioServer(cfg StdioServerConfig) error { t, dumpTranslations := translations.TranslationHelper() ghServer, err := NewMCPServer(MCPServerConfig{ - Version: cfg.Version, - Host: cfg.Host, - Token: cfg.Token, - EnabledToolsets: cfg.EnabledToolsets, - DynamicToolsets: cfg.DynamicToolsets, - ReadOnly: cfg.ReadOnly, - Translator: t, + Version: cfg.Version, + Host: cfg.Host, + Token: cfg.Token, + EnabledToolsets: cfg.EnabledToolsets, + DynamicToolsets: cfg.DynamicToolsets, + ReadOnly: cfg.ReadOnly, + DisableContentFiltering: cfg.DisableContentFiltering, + Translator: t, }) if err != nil { return fmt.Errorf("failed to create MCP server: %w", err) diff --git a/pkg/filtering/content_filter.go b/pkg/filtering/content_filter.go new file mode 100644 index 00000000..c79c7652 --- /dev/null +++ b/pkg/filtering/content_filter.go @@ -0,0 +1,145 @@ +package filtering + +import ( + "regexp" + "strings" +) + +var ( + // Invisible Unicode characters + // This includes zero-width spaces, zero-width joiners, zero-width non-joiners, + // bidirectional marks, and other invisible unicode characters + invisibleCharsRegex = regexp.MustCompile(`[\x{200B}-\x{200F}\x{2028}-\x{202E}\x{2060}-\x{2064}\x{FEFF}]`) + + // HTML comments + htmlCommentsRegex = regexp.MustCompile(``) + + // HTML elements that could contain hidden content + // This is a simple approach that targets specific dangerous tags + // Go's regexp doesn't support backreferences, so we list each tag explicitly + htmlScriptRegex = regexp.MustCompile(`]*>[\s\S]*?`) + htmlStyleRegex = regexp.MustCompile(`]*>[\s\S]*?`) + htmlIframeRegex = regexp.MustCompile(`]*>[\s\S]*?`) + htmlObjectRegex = regexp.MustCompile(`]*>[\s\S]*?`) + htmlEmbedRegex = regexp.MustCompile(`]*>[\s\S]*?`) + htmlSvgRegex = regexp.MustCompile(`]*>[\s\S]*?`) + htmlMathRegex = regexp.MustCompile(`]*>[\s\S]*?`) + htmlLinkRegex = regexp.MustCompile(`]*>[\s\S]*?`) + + // HTML attributes that might be used for hiding content + htmlAttributesRegex = regexp.MustCompile(`<[^>]*(?:style|data-[\w-]+|hidden|class)="[^"]*"[^>]*>`) + + // Detect collapsed sections (details/summary) + collapsedSectionsRegex = regexp.MustCompile(`
[\s\S]*?
`) + + // Very small text (font-size or similar CSS tricks) + smallTextRegex = regexp.MustCompile(`<[^>]*style="[^"]*font-size:\s*(?:0|0\.\d+|[0-3])(?:px|pt|em|%)[^"]*"[^>]*>[\s\S]*?]+>`) + + // Excessive whitespace (more than 3 consecutive newlines) + excessiveWhitespaceRegex = regexp.MustCompile(`\n{4,}`) + + // Excessive spaces (15 or more consecutive spaces) + excessiveSpacesRegex = regexp.MustCompile(` {15,}`) + + // Excessive tabs (6 or more consecutive tabs) + excessiveTabsRegex = regexp.MustCompile(`\t{6,}`) +) + +// Config holds configuration for content filtering +type Config struct { + // DisableContentFiltering disables all content filtering when true + DisableContentFiltering bool +} + +// DefaultConfig returns the default content filtering configuration +func DefaultConfig() *Config { + return &Config{ + DisableContentFiltering: false, + } +} + +// FilterContent filters potentially hidden content from the input text +// This includes invisible Unicode characters, HTML comments, and other methods of hiding content +func FilterContent(input string, cfg *Config) string { + if cfg != nil && cfg.DisableContentFiltering { + return input + } + + if input == "" { + return input + } + + // Process the input text through each filter + result := input + + // Remove invisible characters + result = invisibleCharsRegex.ReplaceAllString(result, "") + + // Replace HTML comments with a marker + result = htmlCommentsRegex.ReplaceAllString(result, "[HTML_COMMENT]") + + // Replace potentially dangerous HTML elements + result = htmlScriptRegex.ReplaceAllString(result, "[HTML_ELEMENT]") + result = htmlStyleRegex.ReplaceAllString(result, "[HTML_ELEMENT]") + result = htmlIframeRegex.ReplaceAllString(result, "[HTML_ELEMENT]") + result = htmlObjectRegex.ReplaceAllString(result, "[HTML_ELEMENT]") + result = htmlEmbedRegex.ReplaceAllString(result, "[HTML_ELEMENT]") + result = htmlSvgRegex.ReplaceAllString(result, "[HTML_ELEMENT]") + result = htmlMathRegex.ReplaceAllString(result, "[HTML_ELEMENT]") + result = htmlLinkRegex.ReplaceAllString(result, "[HTML_ELEMENT]") + + // Replace HTML attributes that might be used for hiding + result = htmlAttributesRegex.ReplaceAllStringFunc(result, cleanHTMLAttributes) + + // Replace collapsed sections with visible indicator + result = collapsedSectionsRegex.ReplaceAllStringFunc(result, makeCollapsedSectionVisible) + + // Replace very small text with visible indicator + result = smallTextRegex.ReplaceAllString(result, "[SMALL_TEXT]") + + // Normalize excessive whitespace + result = excessiveWhitespaceRegex.ReplaceAllString(result, "\n\n\n") + + // Normalize excessive spaces + result = excessiveSpacesRegex.ReplaceAllString(result, " ") + + // Normalize excessive tabs + result = excessiveTabsRegex.ReplaceAllString(result, " ") + + return result +} + +// cleanHTMLAttributes removes potentially dangerous attributes from HTML tags +func cleanHTMLAttributes(tag string) string { + // This is a simple implementation that removes style, data-* and hidden attributes + // A more sophisticated implementation would parse the HTML and selectively remove attributes + tagWithoutStyle := regexp.MustCompile(`\s+(?:style|data-[\w-]+|hidden|class)="[^"]*"`).ReplaceAllString(tag, "") + return tagWithoutStyle +} + +// makeCollapsedSectionVisible transforms a
section to make it visible +func makeCollapsedSectionVisible(detailsSection string) string { + // Extract the summary if present + summaryRegex := regexp.MustCompile(`(.*?)`) + summaryMatches := summaryRegex.FindStringSubmatch(detailsSection) + + summary := "Collapsed section" + if len(summaryMatches) > 1 { + summary = summaryMatches[1] + } + + // Extract the content (everything after and before
) + parts := strings.SplitN(detailsSection, "", 2) + content := detailsSection + if len(parts) > 1 { + content = parts[1] + content = strings.TrimSuffix(content, "") + } else { + // No summary tag found, remove the details tags + content = strings.TrimPrefix(content, "
") + content = strings.TrimSuffix(content, "
") + } + + // Format as a visible section + return "\n\n**" + summary + ":**\n" + content + "\n\n" +} \ No newline at end of file diff --git a/pkg/filtering/content_filter_test.go b/pkg/filtering/content_filter_test.go new file mode 100644 index 00000000..719fd4a7 --- /dev/null +++ b/pkg/filtering/content_filter_test.go @@ -0,0 +1,185 @@ +package filtering + +import ( + "testing" +) + +func TestFilterContent(t *testing.T) { + tests := []struct { + name string + input string + expected string + cfg *Config + }{ + { + name: "Empty string", + input: "", + expected: "", + cfg: DefaultConfig(), + }, + { + name: "Normal text without hidden content", + input: "This is normal text without any hidden content.", + expected: "This is normal text without any hidden content.", + cfg: DefaultConfig(), + }, + { + name: "Text with invisible characters", + input: "Hidden\u200Bcharacters\u200Bin\u200Bthis\u200Btext", + expected: "Hiddencharactersinthistext", + cfg: DefaultConfig(), + }, + { + name: "Text with HTML comments", + input: "This has a in it.", + expected: "This has a [HTML_COMMENT] in it.", + cfg: DefaultConfig(), + }, + { + name: "Text with HTML elements", + input: "This has scripts.", + expected: "This has [HTML_ELEMENT] scripts.", + cfg: DefaultConfig(), + }, + { + name: "Text with details/summary", + input: "Collapsed content:
Click meHidden content
", + expected: "Collapsed content: \n\n**Click me:**\nHidden content\n\n", + cfg: DefaultConfig(), + }, + { + name: "Text with small font", + input: "This has hidden tiny text in it.", + expected: "This has hidden tiny text in it.", + cfg: DefaultConfig(), + }, + { + name: "Text with excessive whitespace", + input: "Line 1\n\n\n\n\n\nLine 2", + expected: "Line 1\n\n\nLine 2", + cfg: DefaultConfig(), + }, + { + name: "Text with excessive spaces", + input: "Normal Excessive", + expected: "Normal Excessive", + cfg: DefaultConfig(), + }, + { + name: "Text with excessive tabs", + input: "Normal\t\t\t\t\t\t\t\tExcessive", + expected: "Normal Excessive", + cfg: DefaultConfig(), + }, + { + name: "Text with HTML attributes", + input: "

Hidden paragraph

", + expected: "

Hidden paragraph

", + cfg: DefaultConfig(), + }, + { + name: "Filtering disabled", + input: "Hidden\u200Bcharacters and ", + expected: "Hidden\u200Bcharacters and ", + cfg: &Config{DisableContentFiltering: true}, + }, + { + name: "Nil config uses default (filtering enabled)", + input: "Hidden\u200Bcharacters", + expected: "Hiddencharacters", + cfg: nil, + }, + { + name: "Normal markdown with code blocks", + input: "# Title\n\n```go\nfunc main() {\n fmt.Println(\"Hello, world!\")\n}\n```", + expected: "# Title\n\n```go\nfunc main() {\n fmt.Println(\"Hello, world!\")\n}\n```", + cfg: DefaultConfig(), + }, + { + name: "GitHub flavored markdown with tables", + input: "| Header 1 | Header 2 |\n| -------- | -------- |\n| Cell 1 | Cell 2 |", + expected: "| Header 1 | Header 2 |\n| -------- | -------- |\n| Cell 1 | Cell 2 |", + cfg: DefaultConfig(), + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := FilterContent(tc.input, tc.cfg) + if result != tc.expected { + t.Errorf("FilterContent() = %q, want %q", result, tc.expected) + } + }) + } +} + +func TestMakeCollapsedSectionVisible(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "Simple details/summary", + input: "
Click meHidden content
", + expected: "\n\n**Click me:**\nHidden content\n\n", + }, + { + name: "Details without summary", + input: "
Hidden content
", + expected: "\n\n**Collapsed section:**\nHidden content\n\n", + }, + { + name: "Nested content", + input: "
OuterContent
InnerNested
", + expected: "\n\n**Outer:**\nContent
InnerNested
\n\n", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := makeCollapsedSectionVisible(tc.input) + if result != tc.expected { + t.Errorf("makeCollapsedSectionVisible() = %q, want %q", result, tc.expected) + } + }) + } +} + +func TestCleanHTMLAttributes(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "Tag with style attribute", + input: "

Hidden

", + expected: "

Hidden

", + }, + { + name: "Tag with data attribute", + input: "

Hidden

", + expected: "

Hidden

", + }, + { + name: "Tag with multiple attributes", + input: "

Hidden

", + expected: "

Hidden

", + }, + { + name: "Tag with allowed attributes", + input: "Link", + expected: "Link", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := cleanHTMLAttributes(tc.input) + if result != tc.expected { + t.Errorf("cleanHTMLAttributes() = %q, want %q", result, tc.expected) + } + }) + } +} \ No newline at end of file diff --git a/pkg/github/filtering.go b/pkg/github/filtering.go new file mode 100644 index 00000000..1c645a40 --- /dev/null +++ b/pkg/github/filtering.go @@ -0,0 +1,205 @@ +package github + +import ( + "github.com/github/github-mcp-server/pkg/filtering" + "github.com/google/go-github/v69/github" +) + +// ContentFilteringConfig holds configuration for content filtering +type ContentFilteringConfig struct { + // DisableContentFiltering disables all content filtering when true + DisableContentFiltering bool +} + +// DefaultContentFilteringConfig returns the default content filtering configuration +func DefaultContentFilteringConfig() *ContentFilteringConfig { + return &ContentFilteringConfig{ + DisableContentFiltering: false, + } +} + +// FilterIssue applies content filtering to issue bodies and titles +func FilterIssue(issue *github.Issue, cfg *ContentFilteringConfig) *github.Issue { + if issue == nil { + return nil + } + + // Don't modify the original issue, create a copy + filteredIssue := *issue + + // Filter the body if present + if issue.Body != nil { + filteredBody := filtering.FilterContent(*issue.Body, &filtering.Config{ + DisableContentFiltering: cfg.DisableContentFiltering, + }) + filteredIssue.Body = github.Ptr(filteredBody) + } + + // Filter the title if present + if issue.Title != nil { + filteredTitle := filtering.FilterContent(*issue.Title, &filtering.Config{ + DisableContentFiltering: cfg.DisableContentFiltering, + }) + filteredIssue.Title = github.Ptr(filteredTitle) + } + + return &filteredIssue +} + +// FilterIssues applies content filtering to a list of issues +func FilterIssues(issues []*github.Issue, cfg *ContentFilteringConfig) []*github.Issue { + if issues == nil { + return nil + } + + filteredIssues := make([]*github.Issue, len(issues)) + for i, issue := range issues { + filteredIssues[i] = FilterIssue(issue, cfg) + } + + return filteredIssues +} + +// FilterPullRequest applies content filtering to pull request bodies and titles +func FilterPullRequest(pr *github.PullRequest, cfg *ContentFilteringConfig) *github.PullRequest { + if pr == nil { + return nil + } + + // Don't modify the original PR, create a copy + filteredPR := *pr + + // Filter the body if present + if pr.Body != nil { + filteredBody := filtering.FilterContent(*pr.Body, &filtering.Config{ + DisableContentFiltering: cfg.DisableContentFiltering, + }) + filteredPR.Body = github.Ptr(filteredBody) + } + + // Filter the title if present + if pr.Title != nil { + filteredTitle := filtering.FilterContent(*pr.Title, &filtering.Config{ + DisableContentFiltering: cfg.DisableContentFiltering, + }) + filteredPR.Title = github.Ptr(filteredTitle) + } + + return &filteredPR +} + +// FilterPullRequests applies content filtering to a list of pull requests +func FilterPullRequests(prs []*github.PullRequest, cfg *ContentFilteringConfig) []*github.PullRequest { + if prs == nil { + return nil + } + + filteredPRs := make([]*github.PullRequest, len(prs)) + for i, pr := range prs { + filteredPRs[i] = FilterPullRequest(pr, cfg) + } + + return filteredPRs +} + +// FilterIssueComment applies content filtering to issue comment bodies +func FilterIssueComment(comment *github.IssueComment, cfg *ContentFilteringConfig) *github.IssueComment { + if comment == nil { + return nil + } + + // Don't modify the original comment, create a copy + filteredComment := *comment + + // Filter the body if present + if comment.Body != nil { + filteredBody := filtering.FilterContent(*comment.Body, &filtering.Config{ + DisableContentFiltering: cfg.DisableContentFiltering, + }) + filteredComment.Body = github.Ptr(filteredBody) + } + + return &filteredComment +} + +// FilterIssueComments applies content filtering to a list of issue comments +func FilterIssueComments(comments []*github.IssueComment, cfg *ContentFilteringConfig) []*github.IssueComment { + if comments == nil { + return nil + } + + filteredComments := make([]*github.IssueComment, len(comments)) + for i, comment := range comments { + filteredComments[i] = FilterIssueComment(comment, cfg) + } + + return filteredComments +} + +// FilterPullRequestComment applies content filtering to pull request comment bodies +func FilterPullRequestComment(comment *github.PullRequestComment, cfg *ContentFilteringConfig) *github.PullRequestComment { + if comment == nil { + return nil + } + + // Don't modify the original comment, create a copy + filteredComment := *comment + + // Filter the body if present + if comment.Body != nil { + filteredBody := filtering.FilterContent(*comment.Body, &filtering.Config{ + DisableContentFiltering: cfg.DisableContentFiltering, + }) + filteredComment.Body = github.Ptr(filteredBody) + } + + return &filteredComment +} + +// FilterPullRequestComments applies content filtering to a list of pull request comments +func FilterPullRequestComments(comments []*github.PullRequestComment, cfg *ContentFilteringConfig) []*github.PullRequestComment { + if comments == nil { + return nil + } + + filteredComments := make([]*github.PullRequestComment, len(comments)) + for i, comment := range comments { + filteredComments[i] = FilterPullRequestComment(comment, cfg) + } + + return filteredComments +} + +// FilterPullRequestReview applies content filtering to pull request review bodies +func FilterPullRequestReview(review *github.PullRequestReview, cfg *ContentFilteringConfig) *github.PullRequestReview { + if review == nil { + return nil + } + + // Don't modify the original review, create a copy + filteredReview := *review + + // Filter the body if present + if review.Body != nil { + filteredBody := filtering.FilterContent(*review.Body, &filtering.Config{ + DisableContentFiltering: cfg.DisableContentFiltering, + }) + filteredReview.Body = github.Ptr(filteredBody) + } + + return &filteredReview +} + +// FilterPullRequestReviews applies content filtering to a list of pull request reviews +func FilterPullRequestReviews(reviews []*github.PullRequestReview, cfg *ContentFilteringConfig) []*github.PullRequestReview { + if reviews == nil { + return nil + } + + filteredReviews := make([]*github.PullRequestReview, len(reviews)) + for i, review := range reviews { + filteredReviews[i] = FilterPullRequestReview(review, cfg) + } + + return filteredReviews +} \ No newline at end of file diff --git a/pkg/github/filtering_test.go b/pkg/github/filtering_test.go new file mode 100644 index 00000000..8a8a97e1 --- /dev/null +++ b/pkg/github/filtering_test.go @@ -0,0 +1,345 @@ +package github + +import ( + "testing" + + "github.com/google/go-github/v69/github" +) + +func TestFilterIssue(t *testing.T) { + tests := []struct { + name string + issue *github.Issue + filterOn bool + expected *github.Issue + }{ + { + name: "nil issue", + issue: nil, + filterOn: true, + expected: nil, + }, + { + name: "no invisible characters", + issue: &github.Issue{ + Title: github.Ptr("Test Issue"), + Body: github.Ptr("This is a test issue"), + }, + filterOn: true, + expected: &github.Issue{ + Title: github.Ptr("Test Issue"), + Body: github.Ptr("This is a test issue"), + }, + }, + { + name: "with invisible characters", + issue: &github.Issue{ + Title: github.Ptr("Test\u200BIssue"), + Body: github.Ptr("This\u200Bis a test issue"), + }, + filterOn: true, + expected: &github.Issue{ + Title: github.Ptr("TestIssue"), + Body: github.Ptr("Thisis a test issue"), + }, + }, + { + name: "with HTML comments", + issue: &github.Issue{ + Title: github.Ptr("Test Issue"), + Body: github.Ptr("This is a test issue"), + }, + filterOn: true, + expected: &github.Issue{ + Title: github.Ptr("Test Issue"), + Body: github.Ptr("This is a [HTML_COMMENT] test issue"), + }, + }, + { + name: "with filtering disabled", + issue: &github.Issue{ + Title: github.Ptr("Test\u200BIssue"), + Body: github.Ptr("This\u200Bis a test issue"), + }, + filterOn: false, + expected: &github.Issue{ + Title: github.Ptr("Test\u200BIssue"), + Body: github.Ptr("This\u200Bis a test issue"), + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + cfg := &ContentFilteringConfig{ + DisableContentFiltering: !tc.filterOn, + } + result := FilterIssue(tc.issue, cfg) + + // For nil input, we expect nil output + if tc.issue == nil { + if result != nil { + t.Fatalf("FilterIssue() = %v, want %v", result, nil) + } + return + } + + // Check title + if *result.Title != *tc.expected.Title { + t.Errorf("FilterIssue().Title = %q, want %q", *result.Title, *tc.expected.Title) + } + + // Check body + if *result.Body != *tc.expected.Body { + t.Errorf("FilterIssue().Body = %q, want %q", *result.Body, *tc.expected.Body) + } + }) + } +} + +func TestFilterPullRequest(t *testing.T) { + tests := []struct { + name string + pr *github.PullRequest + filterOn bool + expected *github.PullRequest + }{ + { + name: "nil pull request", + pr: nil, + filterOn: true, + expected: nil, + }, + { + name: "no invisible characters", + pr: &github.PullRequest{ + Title: github.Ptr("Test PR"), + Body: github.Ptr("This is a test PR"), + }, + filterOn: true, + expected: &github.PullRequest{ + Title: github.Ptr("Test PR"), + Body: github.Ptr("This is a test PR"), + }, + }, + { + name: "with invisible characters", + pr: &github.PullRequest{ + Title: github.Ptr("Test\u200BPR"), + Body: github.Ptr("This\u200Bis a test PR"), + }, + filterOn: true, + expected: &github.PullRequest{ + Title: github.Ptr("TestPR"), + Body: github.Ptr("Thisis a test PR"), + }, + }, + { + name: "with HTML comments", + pr: &github.PullRequest{ + Title: github.Ptr("Test PR"), + Body: github.Ptr("This is a test PR"), + }, + filterOn: true, + expected: &github.PullRequest{ + Title: github.Ptr("Test PR"), + Body: github.Ptr("This is a [HTML_COMMENT] test PR"), + }, + }, + { + name: "with filtering disabled", + pr: &github.PullRequest{ + Title: github.Ptr("Test\u200BPR"), + Body: github.Ptr("This\u200Bis a test PR"), + }, + filterOn: false, + expected: &github.PullRequest{ + Title: github.Ptr("Test\u200BPR"), + Body: github.Ptr("This\u200Bis a test PR"), + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + cfg := &ContentFilteringConfig{ + DisableContentFiltering: !tc.filterOn, + } + result := FilterPullRequest(tc.pr, cfg) + + // For nil input, we expect nil output + if tc.pr == nil { + if result != nil { + t.Fatalf("FilterPullRequest() = %v, want %v", result, nil) + } + return + } + + // Check title + if *result.Title != *tc.expected.Title { + t.Errorf("FilterPullRequest().Title = %q, want %q", *result.Title, *tc.expected.Title) + } + + // Check body + if *result.Body != *tc.expected.Body { + t.Errorf("FilterPullRequest().Body = %q, want %q", *result.Body, *tc.expected.Body) + } + }) + } +} + +func TestFilterIssueComment(t *testing.T) { + tests := []struct { + name string + comment *github.IssueComment + filterOn bool + expected *github.IssueComment + }{ + { + name: "nil comment", + comment: nil, + filterOn: true, + expected: nil, + }, + { + name: "no invisible characters", + comment: &github.IssueComment{ + Body: github.Ptr("This is a test comment"), + }, + filterOn: true, + expected: &github.IssueComment{ + Body: github.Ptr("This is a test comment"), + }, + }, + { + name: "with invisible characters", + comment: &github.IssueComment{ + Body: github.Ptr("This\u200Bis a test comment"), + }, + filterOn: true, + expected: &github.IssueComment{ + Body: github.Ptr("Thisis a test comment"), + }, + }, + { + name: "with HTML comments", + comment: &github.IssueComment{ + Body: github.Ptr("This is a test comment"), + }, + filterOn: true, + expected: &github.IssueComment{ + Body: github.Ptr("This is a [HTML_COMMENT] test comment"), + }, + }, + { + name: "with filtering disabled", + comment: &github.IssueComment{ + Body: github.Ptr("This\u200Bis a test comment"), + }, + filterOn: false, + expected: &github.IssueComment{ + Body: github.Ptr("This\u200Bis a test comment"), + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + cfg := &ContentFilteringConfig{ + DisableContentFiltering: !tc.filterOn, + } + result := FilterIssueComment(tc.comment, cfg) + + // For nil input, we expect nil output + if tc.comment == nil { + if result != nil { + t.Fatalf("FilterIssueComment() = %v, want %v", result, nil) + } + return + } + + // Check body + if *result.Body != *tc.expected.Body { + t.Errorf("FilterIssueComment().Body = %q, want %q", *result.Body, *tc.expected.Body) + } + }) + } +} + +func TestFilterPullRequestComment(t *testing.T) { + tests := []struct { + name string + comment *github.PullRequestComment + filterOn bool + expected *github.PullRequestComment + }{ + { + name: "nil comment", + comment: nil, + filterOn: true, + expected: nil, + }, + { + name: "no invisible characters", + comment: &github.PullRequestComment{ + Body: github.Ptr("This is a test comment"), + }, + filterOn: true, + expected: &github.PullRequestComment{ + Body: github.Ptr("This is a test comment"), + }, + }, + { + name: "with invisible characters", + comment: &github.PullRequestComment{ + Body: github.Ptr("This\u200Bis a test comment"), + }, + filterOn: true, + expected: &github.PullRequestComment{ + Body: github.Ptr("Thisis a test comment"), + }, + }, + { + name: "with HTML comments", + comment: &github.PullRequestComment{ + Body: github.Ptr("This is a test comment"), + }, + filterOn: true, + expected: &github.PullRequestComment{ + Body: github.Ptr("This is a [HTML_COMMENT] test comment"), + }, + }, + { + name: "with filtering disabled", + comment: &github.PullRequestComment{ + Body: github.Ptr("This\u200Bis a test comment"), + }, + filterOn: false, + expected: &github.PullRequestComment{ + Body: github.Ptr("This\u200Bis a test comment"), + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + cfg := &ContentFilteringConfig{ + DisableContentFiltering: !tc.filterOn, + } + result := FilterPullRequestComment(tc.comment, cfg) + + // For nil input, we expect nil output + if tc.comment == nil { + if result != nil { + t.Fatalf("FilterPullRequestComment() = %v, want %v", result, nil) + } + return + } + + // Check body + if *result.Body != *tc.expected.Body { + t.Errorf("FilterPullRequestComment().Body = %q, want %q", *result.Body, *tc.expected.Body) + } + }) + } +} \ No newline at end of file diff --git a/pkg/github/issues.go b/pkg/github/issues.go index 68e7a36c..a3cba5f7 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -70,7 +70,14 @@ func GetIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (tool return mcp.NewToolResultError(fmt.Sprintf("failed to get issue: %s", string(body))), nil } - r, err := json.Marshal(issue) + // Apply content filtering + filterCfg := &ContentFilteringConfig{ + DisableContentFiltering: false, // Default to enabled + } + // TODO: Pass server configuration through client context once it's available + filteredIssue := FilterIssue(issue, filterCfg) + + r, err := json.Marshal(filteredIssue) if err != nil { return nil, fmt.Errorf("failed to marshal issue: %w", err) } @@ -232,6 +239,21 @@ func SearchIssues(getClient GetClientFn, t translations.TranslationHelperFunc) ( return mcp.NewToolResultError(fmt.Sprintf("failed to search issues: %s", string(body))), nil } + // Apply content filtering + filterCfg := &ContentFilteringConfig{ + DisableContentFiltering: false, // Default to enabled + } + // TODO: Pass server configuration through client context once it's available + + // Apply filtering to both issues and pull requests in the search results + if result.Issues != nil { + filteredItems := make([]*github.Issue, len(result.Issues)) + for i, issue := range result.Issues { + filteredItems[i] = FilterIssue(issue, filterCfg) + } + result.Issues = filteredItems + } + r, err := json.Marshal(result) if err != nil { return nil, fmt.Errorf("failed to marshal response: %w", err) @@ -476,7 +498,14 @@ func ListIssues(getClient GetClientFn, t translations.TranslationHelperFunc) (to return mcp.NewToolResultError(fmt.Sprintf("failed to list issues: %s", string(body))), nil } - r, err := json.Marshal(issues) + // Apply content filtering + filterCfg := &ContentFilteringConfig{ + DisableContentFiltering: false, // Default to enabled + } + // TODO: Pass server configuration through client context once it's available + filteredIssues := FilterIssues(issues, filterCfg) + + r, err := json.Marshal(filteredIssues) if err != nil { return nil, fmt.Errorf("failed to marshal issues: %w", err) } @@ -705,7 +734,14 @@ func GetIssueComments(getClient GetClientFn, t translations.TranslationHelperFun return mcp.NewToolResultError(fmt.Sprintf("failed to get issue comments: %s", string(body))), nil } - r, err := json.Marshal(comments) + // Apply content filtering + filterCfg := &ContentFilteringConfig{ + DisableContentFiltering: false, // Default to enabled + } + // TODO: Pass server configuration through client context once it's available + filteredComments := FilterIssueComments(comments, filterCfg) + + r, err := json.Marshal(filteredComments) if err != nil { return nil, fmt.Errorf("failed to marshal response: %w", err) } diff --git a/pkg/github/pullrequests.go b/pkg/github/pullrequests.go index d6dd3f96..bb0d94f5 100644 --- a/pkg/github/pullrequests.go +++ b/pkg/github/pullrequests.go @@ -68,7 +68,14 @@ func GetPullRequest(getClient GetClientFn, t translations.TranslationHelperFunc) return mcp.NewToolResultError(fmt.Sprintf("failed to get pull request: %s", string(body))), nil } - r, err := json.Marshal(pr) + // Apply content filtering + filterCfg := &ContentFilteringConfig{ + DisableContentFiltering: false, // Default to enabled + } + // TODO: Pass server configuration through client context once it's available + filteredPR := FilterPullRequest(pr, filterCfg) + + r, err := json.Marshal(filteredPR) if err != nil { return nil, fmt.Errorf("failed to marshal response: %w", err) } @@ -413,7 +420,14 @@ func ListPullRequests(getClient GetClientFn, t translations.TranslationHelperFun return mcp.NewToolResultError(fmt.Sprintf("failed to list pull requests: %s", string(body))), nil } - r, err := json.Marshal(prs) + // Apply content filtering + filterCfg := &ContentFilteringConfig{ + DisableContentFiltering: false, // Default to enabled + } + // TODO: Pass server configuration through client context once it's available + filteredPRs := FilterPullRequests(prs, filterCfg) + + r, err := json.Marshal(filteredPRs) if err != nil { return nil, fmt.Errorf("failed to marshal response: %w", err) } @@ -788,7 +802,14 @@ func GetPullRequestComments(getClient GetClientFn, t translations.TranslationHel return mcp.NewToolResultError(fmt.Sprintf("failed to get pull request comments: %s", string(body))), nil } - r, err := json.Marshal(comments) + // Apply content filtering + filterCfg := &ContentFilteringConfig{ + DisableContentFiltering: false, // Default to enabled + } + // TODO: Pass server configuration through client context once it's available + filteredComments := FilterPullRequestComments(comments, filterCfg) + + r, err := json.Marshal(filteredComments) if err != nil { return nil, fmt.Errorf("failed to marshal response: %w", err) } @@ -850,7 +871,14 @@ func GetPullRequestReviews(getClient GetClientFn, t translations.TranslationHelp return mcp.NewToolResultError(fmt.Sprintf("failed to get pull request reviews: %s", string(body))), nil } - r, err := json.Marshal(reviews) + // Apply content filtering + filterCfg := &ContentFilteringConfig{ + DisableContentFiltering: false, // Default to enabled + } + // TODO: Pass server configuration through client context once it's available + filteredReviews := FilterPullRequestReviews(reviews, filterCfg) + + r, err := json.Marshal(filteredReviews) if err != nil { return nil, fmt.Errorf("failed to marshal response: %w", err) } diff --git a/pkg/github/server.go b/pkg/github/server.go index e4c24171..dcbcffdf 100644 --- a/pkg/github/server.go +++ b/pkg/github/server.go @@ -9,9 +9,24 @@ import ( "github.com/mark3labs/mcp-go/server" ) -// NewServer creates a new GitHub MCP server with the specified GH client and logger. +// ServerConfig holds configuration for the GitHub MCP server +type ServerConfig struct { + // Version of the server + Version string + + // DisableContentFiltering disables filtering of invisible characters and hidden content + DisableContentFiltering bool +} +// NewServer creates a new GitHub MCP server with the specified GH client and logger. func NewServer(version string, opts ...server.ServerOption) *server.MCPServer { + return NewServerWithConfig(ServerConfig{ + Version: version, + }, opts...) +} + +// NewServerWithConfig creates a new GitHub MCP server with the specified configuration and options. +func NewServerWithConfig(cfg ServerConfig, opts ...server.ServerOption) *server.MCPServer { // Add default options defaultOpts := []server.ServerOption{ server.WithToolCapabilities(true), @@ -23,7 +38,7 @@ func NewServer(version string, opts ...server.ServerOption) *server.MCPServer { // Create a new MCP server s := server.NewMCPServer( "github-mcp-server", - version, + cfg.Version, opts..., ) return s