Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions docs/feature-flags.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

<!-- END AUTOMATED FEATURE FLAG TOOLS -->
Original file line number Diff line number Diff line change
@@ -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"
}
8 changes: 8 additions & 0 deletions pkg/github/feature_flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -40,6 +47,7 @@ var AllowedFeatureFlags = []string{
FeatureFlagPullRequestsGranular,
FeatureFlagFileBlame,
FeatureFlagIssueDependencies,
FeatureFlagDuplicateDetection,
}

// InsidersFeatureFlags is the list of feature flags that insiders mode enables.
Expand Down
175 changes: 175 additions & 0 deletions pkg/github/find_duplicate.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading