Repository: github/github-mcp-server

Issue:
Bug: search_issues tool creates duplicate filters causing 422 validation errors
### Describe the bug

The search_issues tool in the GitHub MCP Server incorrectly adds duplicate repo: and is: filters to user queries, causing GitHub API validation errors when users provide queries that already contain these filters.

Additional Context:
This issue also affects users who use complex queries with multiple OR operators, as the duplicate filters contribute to exceeding GitHub's 5 AND/OR/NOT operator limit.

### Affected version

GitHub MCP Server: Current development version 

### Steps to reproduce the behavior

Use the search_issues tool with a query that already contains repo: and/or is:issue filters
Example request:
```
{
  "tool": "search_issues",
  "arguments": {
    "query": "repo:github/github-mcp-server is:issue is:open (label:critical OR label:urgent OR label:high-priority OR label:blocker OR label:bug OR label:enhancement)"
  }
}
```

### Expected vs actual behavior

Expected Behavior
The tool should detect existing filters and avoid adding duplicates, producing a clean query like:

```
repo:github/github_mcp_server is:issue is:open (label:critical OR label:urgent OR label:high-priority OR label:blocker OR label:bug OR label:enhancement)
```



Actual Behavior
The tool adds duplicate filters, producing malformed queries like:
```
is:issue repo:github/github_mcp_server is:issue is:open (label:critical OR label:urgent OR label:high-priority OR label:blocker OR label:bug OR label:enhancement)
```


### Logs
```
{
  "type": "text", 
  "text": "Error executing code: MCP error -32603: failed to search issues: GET https://api.github.com/search/issues?order=desc&page=1&per_page=10&q=is%3Aissue+repo%3Agithub%2Fgithub_mcp_server+is%3Aissue+is%3Aopen+%28label%3Acritical+OR+label%3Aurgent+OR+label%3Ahigh-priority+OR+label%3Ablocker+OR+label%3Abug+OR+label%3Aenhancement%29&sort=created: 422 Validation Failed [{Resource:Search Field:q Code:invalid Message:More than five AND / OR / NOT operators were used.}]"
}
```

Discussion:

Solution patch:
diff --git a/pkg/github/search.go b/pkg/github/search.go
index 4fe390f..248f17e 100644
--- a/pkg/github/search.go
+++ b/pkg/github/search.go
@@ -204,7 +204,10 @@ func userOrOrgHandler(accountType string, getClient GetClientFn) server.ToolHand
 			return nil, fmt.Errorf("failed to get GitHub client: %w", err)
 		}
 
-		searchQuery := "type:" + accountType + " " + query
+		searchQuery := query
+		if !hasTypeFilter(query) {
+			searchQuery = "type:" + accountType + " " + query
+		}
 		result, resp, err := client.Search.Users(ctx, searchQuery, opts)
 		if err != nil {
 			return ghErrors.NewGitHubAPIErrorResponse(ctx,
diff --git a/pkg/github/search_utils.go b/pkg/github/search_utils.go
index 014b572..159518c 100644
--- a/pkg/github/search_utils.go
+++ b/pkg/github/search_utils.go
@@ -6,11 +6,35 @@ import (
 	"fmt"
 	"io"
 	"net/http"
+	"regexp"
 
 	"github.com/google/go-github/v74/github"
 	"github.com/mark3labs/mcp-go/mcp"
 )
 
+func hasFilter(query, filterType string) bool {
+	// Match filter at start of string, after whitespace, or after non-word characters like '('
+	pattern := fmt.Sprintf(`(^|\s|\W)%s:\S+`, regexp.QuoteMeta(filterType))
+	matched, _ := regexp.MatchString(pattern, query)
+	return matched
+}
+
+func hasSpecificFilter(query, filterType, filterValue string) bool {
+	// Match specific filter:value at start, after whitespace, or after non-word characters
+	// End with word boundary, whitespace, or non-word characters like ')'
+	pattern := fmt.Sprintf(`(^|\s|\W)%s:%s($|\s|\W)`, regexp.QuoteMeta(filterType), regexp.QuoteMeta(filterValue))
+	matched, _ := regexp.MatchString(pattern, query)
+	return matched
+}
+
+func hasRepoFilter(query string) bool {
+	return hasFilter(query, "repo")
+}
+
+func hasTypeFilter(query string) bool {
+	return hasFilter(query, "type")
+}
+
 func searchHandler(
 	ctx context.Context,
 	getClient GetClientFn,
@@ -22,7 +46,10 @@ func searchHandler(
 	if err != nil {
 		return mcp.NewToolResultError(err.Error()), nil
 	}
-	query = fmt.Sprintf("is:%s %s", searchType, query)
+
+	if !hasSpecificFilter(query, "is", searchType) {
+		query = fmt.Sprintf("is:%s %s", searchType, query)
+	}
 
 	owner, err := OptionalParam[string](request, "owner")
 	if err != nil {
@@ -34,7 +61,7 @@ func searchHandler(
 		return mcp.NewToolResultError(err.Error()), nil
 	}
 
-	if owner != "" && repo != "" {
+	if owner != "" && repo != "" && !hasRepoFilter(query) {
 		query = fmt.Sprintf("repo:%s/%s %s", owner, repo, query)
 	}
 



Test patch:
diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go
index 37c9c66..249fade 100644
--- a/pkg/github/issues_test.go
+++ b/pkg/github/issues_test.go
@@ -411,6 +411,100 @@ func Test_SearchIssues(t *testing.T) {
 			expectError:    false,
 			expectedResult: mockSearchResult,
 		},
+		{
+			name: "query with existing is:issue filter - no duplication",
+			mockedClient: mock.NewMockedHTTPClient(
+				mock.WithRequestMatchHandler(
+					mock.GetSearchIssues,
+					expectQueryParams(
+						t,
+						map[string]string{
+							"q":        "repo:github/github-mcp-server is:issue is:open (label:critical OR label:urgent)",
+							"page":     "1",
+							"per_page": "30",
+						},
+					).andThen(
+						mockResponse(t, http.StatusOK, mockSearchResult),
+					),
+				),
+			),
+			requestArgs: map[string]interface{}{
+				"query": "repo:github/github-mcp-server is:issue is:open (label:critical OR label:urgent)",
+			},
+			expectError:    false,
+			expectedResult: mockSearchResult,
+		},
+		{
+			name: "query with existing repo: filter and conflicting owner/repo params - uses query filter",
+			mockedClient: mock.NewMockedHTTPClient(
+				mock.WithRequestMatchHandler(
+					mock.GetSearchIssues,
+					expectQueryParams(
+						t,
+						map[string]string{
+							"q":        "is:issue repo:github/github-mcp-server critical",
+							"page":     "1",
+							"per_page": "30",
+						},
+					).andThen(
+						mockResponse(t, http.StatusOK, mockSearchResult),
+					),
+				),
+			),
+			requestArgs: map[string]interface{}{
+				"query": "repo:github/github-mcp-server critical",
+				"owner": "different-owner",
+				"repo":  "different-repo",
+			},
+			expectError:    false,
+			expectedResult: mockSearchResult,
+		},
+		{
+			name: "query with both is: and repo: filters already present",
+			mockedClient: mock.NewMockedHTTPClient(
+				mock.WithRequestMatchHandler(
+					mock.GetSearchIssues,
+					expectQueryParams(
+						t,
+						map[string]string{
+							"q":        "is:issue repo:octocat/Hello-World bug",
+							"page":     "1",
+							"per_page": "30",
+						},
+					).andThen(
+						mockResponse(t, http.StatusOK, mockSearchResult),
+					),
+				),
+			),
+			requestArgs: map[string]interface{}{
+				"query": "is:issue repo:octocat/Hello-World bug",
+			},
+			expectError:    false,
+			expectedResult: mockSearchResult,
+		},
+		{
+			name: "complex query with multiple OR operators and existing filters",
+			mockedClient: mock.NewMockedHTTPClient(
+				mock.WithRequestMatchHandler(
+					mock.GetSearchIssues,
+					expectQueryParams(
+						t,
+						map[string]string{
+							"q":        "repo:github/github-mcp-server is:issue (label:critical OR label:urgent OR label:high-priority OR label:blocker)",
+							"page":     "1",
+							"per_page": "30",
+						},
+					).andThen(
+						mockResponse(t, http.StatusOK, mockSearchResult),
+					),
+				),
+			),
+			requestArgs: map[string]interface{}{
+				"query": "repo:github/github-mcp-server is:issue (label:critical OR label:urgent OR label:high-priority OR label:blocker)",
+			},
+			expectError:    false,
+			expectedResult: mockSearchResult,
+		},
 		{
 			name: "search issues fails",
 			mockedClient: mock.NewMockedHTTPClient(
diff --git a/pkg/github/pullrequests_test.go b/pkg/github/pullrequests_test.go
index f759885..ed69214 100644
--- a/pkg/github/pullrequests_test.go
+++ b/pkg/github/pullrequests_test.go
@@ -1030,6 +1030,77 @@ func Test_SearchPullRequests(t *testing.T) {
 			expectError:    false,
 			expectedResult: mockSearchResult,
 		},
+		{
+			name: "query with existing is:pr filter - no duplication",
+			mockedClient: mock.NewMockedHTTPClient(
+				mock.WithRequestMatchHandler(
+					mock.GetSearchIssues,
+					expectQueryParams(
+						t,
+						map[string]string{
+							"q":        "is:pr repo:github/github-mcp-server is:open draft:false",
+							"page":     "1",
+							"per_page": "30",
+						},
+					).andThen(
+						mockResponse(t, http.StatusOK, mockSearchResult),
+					),
+				),
+			),
+			requestArgs: map[string]interface{}{
+				"query": "is:pr repo:github/github-mcp-server is:open draft:false",
+			},
+			expectError:    false,
+			expectedResult: mockSearchResult,
+		},
+		{
+			name: "query with existing repo: filter and conflicting owner/repo params - uses query filter",
+			mockedClient: mock.NewMockedHTTPClient(
+				mock.WithRequestMatchHandler(
+					mock.GetSearchIssues,
+					expectQueryParams(
+						t,
+						map[string]string{
+							"q":        "is:pr repo:github/github-mcp-server author:octocat",
+							"page":     "1",
+							"per_page": "30",
+						},
+					).andThen(
+						mockResponse(t, http.StatusOK, mockSearchResult),
+					),
+				),
+			),
+			requestArgs: map[string]interface{}{
+				"query": "repo:github/github-mcp-server author:octocat",
+				"owner": "different-owner",
+				"repo":  "different-repo",
+			},
+			expectError:    false,
+			expectedResult: mockSearchResult,
+		},
+		{
+			name: "complex query with existing is:pr filter and OR operators",
+			mockedClient: mock.NewMockedHTTPClient(
+				mock.WithRequestMatchHandler(
+					mock.GetSearchIssues,
+					expectQueryParams(
+						t,
+						map[string]string{
+							"q":        "is:pr repo:github/github-mcp-server (label:bug OR label:enhancement OR label:feature)",
+							"page":     "1",
+							"per_page": "30",
+						},
+					).andThen(
+						mockResponse(t, http.StatusOK, mockSearchResult),
+					),
+				),
+			),
+			requestArgs: map[string]interface{}{
+				"query": "is:pr repo:github/github-mcp-server (label:bug OR label:enhancement OR label:feature)",
+			},
+			expectError:    false,
+			expectedResult: mockSearchResult,
+		},
 		{
 			name: "search pull requests fails",
 			mockedClient: mock.NewMockedHTTPClient(
diff --git a/pkg/github/search_test.go b/pkg/github/search_test.go
index 66b57a8..cfc87c0 100644
--- a/pkg/github/search_test.go
+++ b/pkg/github/search_test.go
@@ -410,6 +410,46 @@ func Test_SearchUsers(t *testing.T) {
 			expectError:    false,
 			expectedResult: mockSearchResult,
 		},
+		{
+			name: "query with existing type:user filter - no duplication",
+			mockedClient: mock.NewMockedHTTPClient(
+				mock.WithRequestMatchHandler(
+					mock.GetSearchUsers,
+					expectQueryParams(t, map[string]string{
+						"q":        "type:user location:seattle followers:>100",
+						"page":     "1",
+						"per_page": "30",
+					}).andThen(
+						mockResponse(t, http.StatusOK, mockSearchResult),
+					),
+				),
+			),
+			requestArgs: map[string]interface{}{
+				"query": "type:user location:seattle followers:>100",
+			},
+			expectError:    false,
+			expectedResult: mockSearchResult,
+		},
+		{
+			name: "complex query with existing type:user filter and OR operators",
+			mockedClient: mock.NewMockedHTTPClient(
+				mock.WithRequestMatchHandler(
+					mock.GetSearchUsers,
+					expectQueryParams(t, map[string]string{
+						"q":        "type:user (location:seattle OR location:california) followers:>50",
+						"page":     "1",
+						"per_page": "30",
+					}).andThen(
+						mockResponse(t, http.StatusOK, mockSearchResult),
+					),
+				),
+			),
+			requestArgs: map[string]interface{}{
+				"query": "type:user (location:seattle OR location:california) followers:>50",
+			},
+			expectError:    false,
+			expectedResult: mockSearchResult,
+		},
 		{
 			name: "search users fails",
 			mockedClient: mock.NewMockedHTTPClient(
@@ -537,6 +577,46 @@ func Test_SearchOrgs(t *testing.T) {
 			expectError:    false,
 			expectedResult: mockSearchResult,
 		},
+		{
+			name: "query with existing type:org filter - no duplication",
+			mockedClient: mock.NewMockedHTTPClient(
+				mock.WithRequestMatchHandler(
+					mock.GetSearchUsers,
+					expectQueryParams(t, map[string]string{
+						"q":        "type:org location:california followers:>1000",
+						"page":     "1",
+						"per_page": "30",
+					}).andThen(
+						mockResponse(t, http.StatusOK, mockSearchResult),
+					),
+				),
+			),
+			requestArgs: map[string]interface{}{
+				"query": "type:org location:california followers:>1000",
+			},
+			expectError:    false,
+			expectedResult: mockSearchResult,
+		},
+		{
+			name: "complex query with existing type:org filter and OR operators",
+			mockedClient: mock.NewMockedHTTPClient(
+				mock.WithRequestMatchHandler(
+					mock.GetSearchUsers,
+					expectQueryParams(t, map[string]string{
+						"q":        "type:org (location:seattle OR location:california OR location:newyork) repos:>10",
+						"page":     "1",
+						"per_page": "30",
+					}).andThen(
+						mockResponse(t, http.StatusOK, mockSearchResult),
+					),
+				),
+			),
+			requestArgs: map[string]interface{}{
+				"query": "type:org (location:seattle OR location:california OR location:newyork) repos:>10",
+			},
+			expectError:    false,
+			expectedResult: mockSearchResult,
+		},
 		{
 			name: "org search fails",
 			mockedClient: mock.NewMockedHTTPClient(
diff --git a/pkg/github/search_utils_test.go b/pkg/github/search_utils_test.go
new file mode 100644
index 0000000..85f953e
--- /dev/null
+++ b/pkg/github/search_utils_test.go
@@ -0,0 +1,352 @@
+package github
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func Test_hasFilter(t *testing.T) {
+	tests := []struct {
+		name       string
+		query      string
+		filterType string
+		expected   bool
+	}{
+		{
+			name:       "query has is:issue filter",
+			query:      "is:issue bug report",
+			filterType: "is",
+			expected:   true,
+		},
+		{
+			name:       "query has repo: filter",
+			query:      "repo:github/github-mcp-server critical bug",
+			filterType: "repo",
+			expected:   true,
+		},
+		{
+			name:       "query has multiple is: filters",
+			query:      "is:issue is:open bug",
+			filterType: "is",
+			expected:   true,
+		},
+		{
+			name:       "query has filter at the beginning",
+			query:      "is:issue some text",
+			filterType: "is",
+			expected:   true,
+		},
+		{
+			name:       "query has filter in the middle",
+			query:      "some text is:issue more text",
+			filterType: "is",
+			expected:   true,
+		},
+		{
+			name:       "query has filter at the end",
+			query:      "some text is:issue",
+			filterType: "is",
+			expected:   true,
+		},
+		{
+			name:       "query does not have the filter",
+			query:      "bug report critical",
+			filterType: "is",
+			expected:   false,
+		},
+		{
+			name:       "query has similar text but not the filter",
+			query:      "this issue is important",
+			filterType: "is",
+			expected:   false,
+		},
+		{
+			name:       "empty query",
+			query:      "",
+			filterType: "is",
+			expected:   false,
+		},
+		{
+			name:       "query has label: filter but looking for is:",
+			query:      "label:bug critical",
+			filterType: "is",
+			expected:   false,
+		},
+		{
+			name:       "query has author: filter",
+			query:      "author:octocat bug",
+			filterType: "author",
+			expected:   true,
+		},
+		{
+			name:       "query with complex OR expression",
+			query:      "repo:github/github-mcp-server is:issue (label:critical OR label:urgent)",
+			filterType: "is",
+			expected:   true,
+		},
+		{
+			name:       "query with complex OR expression checking repo",
+			query:      "repo:github/github-mcp-server is:issue (label:critical OR label:urgent)",
+			filterType: "repo",
+			expected:   true,
+		},
+		{
+			name:       "filter in parentheses at start",
+			query:      "(label:bug OR owner:bob) is:issue",
+			filterType: "label",
+			expected:   true,
+		},
+		{
+			name:       "filter after opening parenthesis",
+			query:      "is:issue (label:critical OR repo:test/test)",
+			filterType: "label",
+			expected:   true,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			result := hasFilter(tt.query, tt.filterType)
+			assert.Equal(t, tt.expected, result, "hasFilter(%q, %q) = %v, expected %v", tt.query, tt.filterType, result, tt.expected)
+		})
+	}
+}
+
+func Test_hasRepoFilter(t *testing.T) {
+	tests := []struct {
+		name     string
+		query    string
+		expected bool
+	}{
+		{
+			name:     "query with repo: filter at beginning",
+			query:    "repo:github/github-mcp-server is:issue",
+			expected: true,
+		},
+		{
+			name:     "query with repo: filter in middle",
+			query:    "is:issue repo:octocat/Hello-World bug",
+			expected: true,
+		},
+		{
+			name:     "query with repo: filter at end",
+			query:    "is:issue critical repo:owner/repo-name",
+			expected: true,
+		},
+		{
+			name:     "query with complex repo name",
+			query:    "repo:microsoft/vscode-extension-samples bug",
+			expected: true,
+		},
+		{
+			name:     "query without repo: filter",
+			query:    "is:issue bug critical",
+			expected: false,
+		},
+		{
+			name:     "query with malformed repo: filter (no slash)",
+			query:    "repo:github bug",
+			expected: true, // hasRepoFilter only checks for repo: prefix, not format
+		},
+		{
+			name:     "empty query",
+			query:    "",
+			expected: false,
+		},
+		{
+			name:     "query with multiple repo: filters",
+			query:    "repo:github/first repo:octocat/second",
+			expected: true,
+		},
+		{
+			name:     "query with repo: in text but not as filter",
+			query:    "this repo: is important",
+			expected: false,
+		},
+		{
+			name:     "query with complex OR expression",
+			query:    "repo:github/github-mcp-server is:issue (label:critical OR label:urgent)",
+			expected: true,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			result := hasRepoFilter(tt.query)
+			assert.Equal(t, tt.expected, result, "hasRepoFilter(%q) = %v, expected %v", tt.query, result, tt.expected)
+		})
+	}
+}
+
+func Test_hasSpecificFilter(t *testing.T) {
+	tests := []struct {
+		name        string
+		query       string
+		filterType  string
+		filterValue string
+		expected    bool
+	}{
+		{
+			name:        "query has exact is:issue filter",
+			query:       "is:issue bug report",
+			filterType:  "is",
+			filterValue: "issue",
+			expected:    true,
+		},
+		{
+			name:        "query has is:open but looking for is:issue",
+			query:       "is:open bug report",
+			filterType:  "is",
+			filterValue: "issue",
+			expected:    false,
+		},
+		{
+			name:        "query has both is:issue and is:open, looking for is:issue",
+			query:       "is:issue is:open bug",
+			filterType:  "is",
+			filterValue: "issue",
+			expected:    true,
+		},
+		{
+			name:        "query has both is:issue and is:open, looking for is:open",
+			query:       "is:issue is:open bug",
+			filterType:  "is",
+			filterValue: "open",
+			expected:    true,
+		},
+		{
+			name:        "query has is:issue at the beginning",
+			query:       "is:issue some text",
+			filterType:  "is",
+			filterValue: "issue",
+			expected:    true,
+		},
+		{
+			name:        "query has is:issue in the middle",
+			query:       "some text is:issue more text",
+			filterType:  "is",
+			filterValue: "issue",
+			expected:    true,
+		},
+		{
+			name:        "query has is:issue at the end",
+			query:       "some text is:issue",
+			filterType:  "is",
+			filterValue: "issue",
+			expected:    true,
+		},
+		{
+			name:        "query does not have is:issue",
+			query:       "bug report critical",
+			filterType:  "is",
+			filterValue: "issue",
+			expected:    false,
+		},
+		{
+			name:        "query has similar text but not the exact filter",
+			query:       "this issue is important",
+			filterType:  "is",
+			filterValue: "issue",
+			expected:    false,
+		},
+		{
+			name:        "empty query",
+			query:       "",
+			filterType:  "is",
+			filterValue: "issue",
+			expected:    false,
+		},
+		{
+			name:        "partial match should not count",
+			query:       "is:issues bug", // "issues" vs "issue"
+			filterType:  "is",
+			filterValue: "issue",
+			expected:    false,
+		},
+		{
+			name:        "complex query with parentheses",
+			query:       "repo:github/github-mcp-server is:issue (label:critical OR label:urgent)",
+			filterType:  "is",
+			filterValue: "issue",
+			expected:    true,
+		},
+		{
+			name:        "filter:value in parentheses at start",
+			query:       "(is:issue OR is:pr) label:bug",
+			filterType:  "is",
+			filterValue: "issue",
+			expected:    true,
+		},
+		{
+			name:        "filter:value after opening parenthesis",
+			query:       "repo:test/repo (is:issue AND label:bug)",
+			filterType:  "is",
+			filterValue: "issue",
+			expected:    true,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			result := hasSpecificFilter(tt.query, tt.filterType, tt.filterValue)
+			assert.Equal(t, tt.expected, result, "hasSpecificFilter(%q, %q, %q) = %v, expected %v", tt.query, tt.filterType, tt.filterValue, result, tt.expected)
+		})
+	}
+}
+
+func Test_hasTypeFilter(t *testing.T) {
+	tests := []struct {
+		name     string
+		query    string
+		expected bool
+	}{
+		{
+			name:     "query with type:user filter at beginning",
+			query:    "type:user location:seattle",
+			expected: true,
+		},
+		{
+			name:     "query with type:org filter in middle",
+			query:    "location:california type:org followers:>100",
+			expected: true,
+		},
+		{
+			name:     "query with type:user filter at end",
+			query:    "location:seattle followers:>50 type:user",
+			expected: true,
+		},
+		{
+			name:     "query without type: filter",
+			query:    "location:seattle followers:>50",
+			expected: false,
+		},
+		{
+			name:     "empty query",
+			query:    "",
+			expected: false,
+		},
+		{
+			name:     "query with type: in text but not as filter",
+			query:    "this type: is important",
+			expected: false,
+		},
+		{
+			name:     "query with multiple type: filters",
+			query:    "type:user type:org",
+			expected: true,
+		},
+		{
+			name:     "complex query with OR expression",
+			query:    "type:user (location:seattle OR location:california)",
+			expected: true,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			result := hasTypeFilter(tt.query)
+			assert.Equal(t, tt.expected, result, "hasTypeFilter(%q) = %v, expected %v", tt.query, result, tt.expected)
+		})
+	}
+}