diff --git a/docs/feature-flags.md b/docs/feature-flags.md index 32891af62e..0069ecd4db 100644 --- a/docs/feature-flags.md +++ b/docs/feature-flags.md @@ -338,4 +338,15 @@ runtime behavior (such as output formatting) won't appear here. - 'blocked_by' - the subject issue is blocked by the related issue. - 'blocking' - the subject issue blocks the related issue. (string, required) +### `duplicate_detection` + +- **find_duplicate** - Find duplicate issues + - **Required OAuth Scopes**: `repo` + - `confidence_threshold`: Minimum similarity threshold a candidate must meet to be returned; higher values are stricter. When omitted, the API's high-precision default is used. The scale is defined by the API, so no client-side bounds are enforced. (number, optional) + - `issue_number`: The number of the existing issue to find duplicates for (number, required) + - `owner`: The owner of the repository (string, required) + - `page`: Page number for pagination (min 1) (number, optional) + - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) + - `repo`: The name of the repository (string, required) + diff --git a/pkg/github/__toolsnaps__/find_duplicate_ff_duplicate_detection.snap b/pkg/github/__toolsnaps__/find_duplicate_ff_duplicate_detection.snap new file mode 100644 index 0000000000..ac95fd4138 --- /dev/null +++ b/pkg/github/__toolsnaps__/find_duplicate_ff_duplicate_detection.snap @@ -0,0 +1,46 @@ +{ + "annotations": { + "idempotentHint": false, + "readOnlyHint": true, + "title": "Find duplicate issues" + }, + "description": "Find likely duplicate issues for an existing issue in a GitHub repository. This is a read-only search scoped to the source issue's repository: it returns ranked candidate issues with a similarity score and confidence, and does not close, link, comment on, or otherwise modify any issue.", + "inputSchema": { + "properties": { + "confidence_threshold": { + "description": "Minimum similarity threshold a candidate must meet to be returned; higher values are stricter. When omitted, the API's high-precision default is used. The scale is defined by the API, so no client-side bounds are enforced.", + "type": "number" + }, + "issue_number": { + "description": "The number of the existing issue to find duplicates for", + "type": "number" + }, + "owner": { + "description": "The owner of the repository", + "type": "string" + }, + "page": { + "description": "Page number for pagination (min 1)", + "minimum": 1, + "type": "number" + }, + "perPage": { + "description": "Results per page for pagination (min 1, max 100)", + "maximum": 100, + "minimum": 1, + "type": "number" + }, + "repo": { + "description": "The name of the repository", + "type": "string" + } + }, + "required": [ + "owner", + "repo", + "issue_number" + ], + "type": "object" + }, + "name": "find_duplicate" +} \ No newline at end of file diff --git a/pkg/github/feature_flags.go b/pkg/github/feature_flags.go index abf5de1e95..4ecd42b653 100644 --- a/pkg/github/feature_flags.go +++ b/pkg/github/feature_flags.go @@ -27,6 +27,13 @@ const FeatureFlagFileBlame = "file_blame" // unless explicitly opted in. const FeatureFlagIssueDependencies = "issue_dependencies" +// FeatureFlagDuplicateDetection is the feature flag name for the find_duplicate +// tool, which returns ranked duplicate candidates for an existing issue. It is +// gated so the extra tool is not advertised by default, and is deliberately +// excluded from insiders mode so duplicate detection is only ever an explicit +// opt-in. +const FeatureFlagDuplicateDetection = "duplicate_detection" + // AllowedFeatureFlags is the allowlist of feature flags that can be enabled // by users via --features CLI flag or X-MCP-Features HTTP header. // Only flags in this list are accepted; unknown flags are silently ignored. @@ -40,6 +47,7 @@ var AllowedFeatureFlags = []string{ FeatureFlagPullRequestsGranular, FeatureFlagFileBlame, FeatureFlagIssueDependencies, + FeatureFlagDuplicateDetection, } // InsidersFeatureFlags is the list of feature flags that insiders mode enables. diff --git a/pkg/github/find_duplicate.go b/pkg/github/find_duplicate.go new file mode 100644 index 0000000000..2d587f4ffa --- /dev/null +++ b/pkg/github/find_duplicate.go @@ -0,0 +1,175 @@ +package github + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strconv" + + ghErrors "github.com/github/github-mcp-server/pkg/errors" + "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/scopes" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/github/github-mcp-server/pkg/utils" + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// rankedSimilarIssue is a single "Ranked Similar Issue" element returned by the +// semantic-similarity endpoint. Only the issue fields the tool surfaces are +// decoded, and Score is nullable because the API may omit a similarity score. +type rankedSimilarIssue struct { + Issue *struct { + Number int `json:"number"` + Title string `json:"title"` + State string `json:"state"` + HTMLURL string `json:"html_url"` + } `json:"issue"` + Score *float64 `json:"score"` + Confidence string `json:"confidence"` + LikelyDuplicate bool `json:"likely_duplicate"` +} + +// duplicateCandidate is the trimmed output for a ranked duplicate candidate, +// carrying only what an agent needs to explain and act on it. +type duplicateCandidate struct { + Issue MinimalIssueRef `json:"issue"` + Score *float64 `json:"score"` + Confidence string `json:"confidence"` + LikelyDuplicate bool `json:"likely_duplicate"` +} + +// FindDuplicate creates a read-only tool that returns ranked duplicate +// candidates for an existing issue. It is a separate, feature-flagged tool so +// duplicate detection is only advertised when explicitly opted in, keeping the +// default tool surface small. The semantic ranking itself is owned by the API; +// this tool only forwards the request and projects the ranked results. +func FindDuplicate(t translations.TranslationHelperFunc) inventory.ServerTool { + schema := &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": { + Type: "string", + Description: "The owner of the repository", + }, + "repo": { + Type: "string", + Description: "The name of the repository", + }, + "issue_number": { + Type: "number", + Description: "The number of the existing issue to find duplicates for", + }, + "confidence_threshold": { + Type: "number", + Description: "Minimum similarity threshold a candidate must meet to be returned; higher values are stricter. When omitted, the API's high-precision default is used. The scale is defined by the API, so no client-side bounds are enforced.", + }, + }, + Required: []string{"owner", "repo", "issue_number"}, + } + WithPagination(schema) + + st := NewTool( + ToolsetMetadataIssues, + mcp.Tool{ + Name: "find_duplicate", + Description: t("TOOL_FIND_DUPLICATE_DESCRIPTION", "Find likely duplicate issues for an existing issue in a GitHub repository. This is a read-only search scoped to the source issue's repository: it returns ranked candidate issues with a similarity score and confidence, and does not close, link, comment on, or otherwise modify any issue."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_FIND_DUPLICATE_USER_TITLE", "Find duplicate issues"), + ReadOnlyHint: true, + }, + InputSchema: schema, + }, + []scopes.Scope{scopes.Repo}, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + repo, err := RequiredParam[string](args, "repo") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + issueNumber, err := RequiredInt(args, "issue_number") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + // Build the query preserving whether each optional value was supplied + // so unset parameters fall back to the API's own defaults. + query := url.Values{} + if threshold, ok, err := OptionalParamOK[float64](args, "confidence_threshold"); err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } else if ok { + query.Set("threshold", strconv.FormatFloat(threshold, 'g', -1, 64)) + } + if _, ok := args["perPage"]; ok { + perPage, err := OptionalIntParam(args, "perPage") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + query.Set("per_page", strconv.Itoa(perPage)) + } + if _, ok := args["page"]; ok { + page, err := OptionalIntParam(args, "page") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + query.Set("page", strconv.Itoa(page)) + } + + client, err := deps.GetClient(ctx) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil + } + + apiURL := fmt.Sprintf("repos/%s/%s/issues/%d/semantically_similar", owner, repo, issueNumber) + if encoded := query.Encode(); encoded != "" { + apiURL += "?" + encoded + } + + req, err := client.NewRequest(ctx, http.MethodGet, apiURL, nil) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to create request", err), nil, nil + } + + var results []rankedSimilarIssue + resp, err := client.Do(req, &results) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to find duplicate issues", resp, err), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + candidates := make([]duplicateCandidate, 0, len(results)) + for _, res := range results { + // A bare issue (no ranking metadata) means ranked duplicate + // detection is not enabled for this caller; fail clearly rather + // than returning incomplete candidates. + if res.Confidence == "" || res.Issue == nil { + return utils.NewToolResultError("ranked duplicate detection is unavailable: the semantic-similarity endpoint returned issues without ranking metadata (the server-side duplicate-ranking feature is not enabled for this caller or repository)"), nil, nil + } + candidates = append(candidates, duplicateCandidate{ + Issue: MinimalIssueRef{ + Number: res.Issue.Number, + Title: res.Issue.Title, + State: res.Issue.State, + URL: res.Issue.HTMLURL, + }, + Score: res.Score, + Confidence: res.Confidence, + LikelyDuplicate: res.LikelyDuplicate, + }) + } + + r, err := json.Marshal(candidates) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to marshal duplicate candidates", err), nil, nil + } + + return utils.NewToolResultText(string(r)), nil, nil + }) + st.FeatureFlagEnable = FeatureFlagDuplicateDetection + return st +} diff --git a/pkg/github/find_duplicate_test.go b/pkg/github/find_duplicate_test.go new file mode 100644 index 0000000000..586b14477e --- /dev/null +++ b/pkg/github/find_duplicate_test.go @@ -0,0 +1,240 @@ +package github + +import ( + "context" + "encoding/json" + "net/http" + "net/url" + "testing" + + "github.com/github/github-mcp-server/internal/toolsnaps" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/google/jsonschema-go/jsonschema" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const endpointSemanticallySimilar = EndpointPattern("GET /repos/{owner}/{repo}/issues/{issue_number}/semantically_similar") + +func Test_FindDuplicate(t *testing.T) { + // Verify tool definition once (flag-gated variant snap). + serverTool := FindDuplicate(translations.NullTranslationHelper) + tool := serverTool.Tool + require.NoError(t, toolsnaps.Test(tool.Name+"_ff_"+FeatureFlagDuplicateDetection, tool)) + require.Equal(t, FeatureFlagDuplicateDetection, serverTool.FeatureFlagEnable) + + assert.Equal(t, "find_duplicate", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.True(t, tool.Annotations.ReadOnlyHint) + assert.ElementsMatch(t, serverTool.RequiredScopes, []string{"repo"}) + + schema := tool.InputSchema.(*jsonschema.Schema) + assert.Contains(t, schema.Properties, "owner") + assert.Contains(t, schema.Properties, "repo") + assert.Contains(t, schema.Properties, "issue_number") + assert.Contains(t, schema.Properties, "confidence_threshold") + assert.Contains(t, schema.Properties, "page") + assert.Contains(t, schema.Properties, "perPage") + assert.ElementsMatch(t, schema.Required, []string{"owner", "repo", "issue_number"}) +} + +func Test_FindDuplicate_RankedResults(t *testing.T) { + serverTool := FindDuplicate(translations.NullTranslationHelper) + + rankedResults := []map[string]any{ + { + "issue": map[string]any{ + "number": 456, + "title": "Example failure when saving", + "state": "open", + "html_url": "https://github.com/owner/repo/issues/456", + }, + "score": 0.95, + "confidence": "high", + "likely_duplicate": true, + }, + { + "issue": map[string]any{ + "number": 789, + "title": "Possibly related", + "state": "closed", + "html_url": "https://github.com/owner/repo/issues/789", + }, + "score": nil, // score is nullable + "confidence": "low", + "likely_duplicate": false, + }, + } + + var capturedURL *url.URL + var capturedMethod string + handler := func(w http.ResponseWriter, r *http.Request) { + capturedURL = r.URL + capturedMethod = r.Method + w.WriteHeader(http.StatusOK) + _, _ = w.Write(MustMarshal(rankedResults)) + } + + client := mustNewGHClient(t, NewMockedHTTPClient(WithRequestMatchHandler(endpointSemanticallySimilar, http.HandlerFunc(handler)))) + deps := BaseDeps{Client: client} + toolHandler := serverTool.Handler(deps) + + request := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(123), + "confidence_threshold": float64(0.8), + "perPage": float64(10), + "page": float64(1), + }) + result, err := toolHandler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError, "expected result to not be an error") + + // The tool must be read-only: only a GET is issued. + assert.Equal(t, http.MethodGet, capturedMethod) + + // confidence_threshold maps to threshold; perPage maps to per_page; page is forwarded. + require.NotNil(t, capturedURL) + assert.Equal(t, "0.8", capturedURL.Query().Get("threshold")) + assert.Equal(t, "10", capturedURL.Query().Get("per_page")) + assert.Equal(t, "1", capturedURL.Query().Get("page")) + + text := getTextResult(t, result) + var candidates []duplicateCandidate + require.NoError(t, json.Unmarshal([]byte(text.Text), &candidates)) + require.Len(t, candidates, 2) + + assert.Equal(t, "high", candidates[0].Confidence) + assert.True(t, candidates[0].LikelyDuplicate) + require.NotNil(t, candidates[0].Score) + assert.InDelta(t, 0.95, *candidates[0].Score, 0.0001) + assert.Equal(t, 456, candidates[0].Issue.Number) + assert.Equal(t, "Example failure when saving", candidates[0].Issue.Title) + assert.Equal(t, "open", candidates[0].Issue.State) + assert.Equal(t, "https://github.com/owner/repo/issues/456", candidates[0].Issue.URL) + + // A null score must decode successfully. + assert.Nil(t, candidates[1].Score) + assert.Equal(t, "low", candidates[1].Confidence) + assert.False(t, candidates[1].LikelyDuplicate) +} + +func Test_FindDuplicate_OmitsUnsetParams(t *testing.T) { + serverTool := FindDuplicate(translations.NullTranslationHelper) + + var capturedURL *url.URL + handler := func(w http.ResponseWriter, r *http.Request) { + capturedURL = r.URL + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`[]`)) + } + + client := mustNewGHClient(t, NewMockedHTTPClient(WithRequestMatchHandler(endpointSemanticallySimilar, http.HandlerFunc(handler)))) + deps := BaseDeps{Client: client} + toolHandler := serverTool.Handler(deps) + + request := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(123), + }) + result, err := toolHandler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + require.NotNil(t, capturedURL) + q := capturedURL.Query() + _, hasThreshold := q["threshold"] + _, hasPerPage := q["per_page"] + _, hasPage := q["page"] + assert.False(t, hasThreshold, "threshold should be omitted when unset") + assert.False(t, hasPerPage, "per_page should be omitted when unset") + assert.False(t, hasPage, "page should be omitted when unset") +} + +func Test_FindDuplicate_EmptyResults(t *testing.T) { + serverTool := FindDuplicate(translations.NullTranslationHelper) + + client := mustNewGHClient(t, NewMockedHTTPClient(WithRequestMatch(endpointSemanticallySimilar, []map[string]any{}))) + deps := BaseDeps{Client: client} + toolHandler := serverTool.Handler(deps) + + request := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(123), + }) + result, err := toolHandler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError, "empty results is a successful search") + + text := getTextResult(t, result) + var candidates []duplicateCandidate + require.NoError(t, json.Unmarshal([]byte(text.Text), &candidates)) + assert.Empty(t, candidates) +} + +func Test_FindDuplicate_LegacyBareIssueResponse(t *testing.T) { + serverTool := FindDuplicate(translations.NullTranslationHelper) + + // When ranked duplicate detection is disabled the endpoint returns bare + // issue resources (no ranking metadata), which must fail clearly. + bareIssues := []map[string]any{ + { + "number": 456, + "title": "Example", + "state": "open", + "html_url": "https://github.com/owner/repo/issues/456", + }, + } + + client := mustNewGHClient(t, NewMockedHTTPClient(WithRequestMatch(endpointSemanticallySimilar, bareIssues))) + deps := BaseDeps{Client: client} + toolHandler := serverTool.Handler(deps) + + request := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(123), + }) + result, err := toolHandler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + getErrorResult(t, result) +} + +func Test_FindDuplicate_Errors(t *testing.T) { + serverTool := FindDuplicate(translations.NullTranslationHelper) + + t.Run("missing required param", func(t *testing.T) { + client := mustNewGHClient(t, NewMockedHTTPClient()) + deps := BaseDeps{Client: client} + toolHandler := serverTool.Handler(deps) + request := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + }) + result, err := toolHandler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + getErrorResult(t, result) + }) + + t.Run("API error is surfaced", func(t *testing.T) { + client := mustNewGHClient(t, NewMockedHTTPClient( + WithRequestMatchHandler(endpointSemanticallySimilar, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + })), + )) + deps := BaseDeps{Client: client} + toolHandler := serverTool.Handler(deps) + request := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(123), + }) + result, err := toolHandler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + getErrorResult(t, result) + }) +} diff --git a/pkg/github/tools.go b/pkg/github/tools.go index 7bae64d2e8..f695c661b1 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -228,6 +228,7 @@ func AllTools(t translations.TranslationHelperFunc) []inventory.ServerTool { SubIssueWrite(t), IssueDependencyRead(t), IssueDependencyWrite(t), + FindDuplicate(t), // User tools SearchUsers(t),