Skip to content

Commit 1b3f89a

Browse files
Add non-default find_duplicate tool gated by duplicate_detection flag (#3020)
* Add non-default find_duplicate tool gated by duplicate_detection flag * Trim find_duplicate output to spec fields and relax confidence_threshold bounds * Attach repo-visibility IFC label to find_duplicate results
1 parent e7f7bb8 commit 1b3f89a

6 files changed

Lines changed: 585 additions & 0 deletions

File tree

docs/feature-flags.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,4 +338,15 @@ runtime behavior (such as output formatting) won't appear here.
338338
- 'blocked_by' - the subject issue is blocked by the related issue.
339339
- 'blocking' - the subject issue blocks the related issue. (string, required)
340340

341+
### `duplicate_detection`
342+
343+
- **find_duplicate** - Find duplicate issues
344+
- **Required OAuth Scopes**: `repo`
345+
- `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)
346+
- `issue_number`: The number of the existing issue to find duplicates for (number, required)
347+
- `owner`: The owner of the repository (string, required)
348+
- `page`: Page number for pagination (min 1) (number, optional)
349+
- `perPage`: Results per page for pagination (min 1, max 100) (number, optional)
350+
- `repo`: The name of the repository (string, required)
351+
341352
<!-- END AUTOMATED FEATURE FLAG TOOLS -->
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
{
2+
"annotations": {
3+
"idempotentHint": false,
4+
"readOnlyHint": true,
5+
"title": "Find duplicate issues"
6+
},
7+
"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.",
8+
"inputSchema": {
9+
"properties": {
10+
"confidence_threshold": {
11+
"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.",
12+
"type": "number"
13+
},
14+
"issue_number": {
15+
"description": "The number of the existing issue to find duplicates for",
16+
"type": "number"
17+
},
18+
"owner": {
19+
"description": "The owner of the repository",
20+
"type": "string"
21+
},
22+
"page": {
23+
"description": "Page number for pagination (min 1)",
24+
"minimum": 1,
25+
"type": "number"
26+
},
27+
"perPage": {
28+
"description": "Results per page for pagination (min 1, max 100)",
29+
"maximum": 100,
30+
"minimum": 1,
31+
"type": "number"
32+
},
33+
"repo": {
34+
"description": "The name of the repository",
35+
"type": "string"
36+
}
37+
},
38+
"required": [
39+
"owner",
40+
"repo",
41+
"issue_number"
42+
],
43+
"type": "object"
44+
},
45+
"name": "find_duplicate"
46+
}

pkg/github/feature_flags.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,13 @@ const FeatureFlagFileBlame = "file_blame"
2727
// unless explicitly opted in.
2828
const FeatureFlagIssueDependencies = "issue_dependencies"
2929

30+
// FeatureFlagDuplicateDetection is the feature flag name for the find_duplicate
31+
// tool, which returns ranked duplicate candidates for an existing issue. It is
32+
// gated so the extra tool is not advertised by default, and is deliberately
33+
// excluded from insiders mode so duplicate detection is only ever an explicit
34+
// opt-in.
35+
const FeatureFlagDuplicateDetection = "duplicate_detection"
36+
3037
// AllowedFeatureFlags is the allowlist of feature flags that can be enabled
3138
// by users via --features CLI flag or X-MCP-Features HTTP header.
3239
// Only flags in this list are accepted; unknown flags are silently ignored.
@@ -40,6 +47,7 @@ var AllowedFeatureFlags = []string{
4047
FeatureFlagPullRequestsGranular,
4148
FeatureFlagFileBlame,
4249
FeatureFlagIssueDependencies,
50+
FeatureFlagDuplicateDetection,
4351
}
4452

4553
// InsidersFeatureFlags is the list of feature flags that insiders mode enables.

pkg/github/find_duplicate.go

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
package github
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"net/http"
8+
"net/url"
9+
"strconv"
10+
11+
ghErrors "github.com/github/github-mcp-server/pkg/errors"
12+
"github.com/github/github-mcp-server/pkg/ifc"
13+
"github.com/github/github-mcp-server/pkg/inventory"
14+
"github.com/github/github-mcp-server/pkg/scopes"
15+
"github.com/github/github-mcp-server/pkg/translations"
16+
"github.com/github/github-mcp-server/pkg/utils"
17+
"github.com/google/jsonschema-go/jsonschema"
18+
"github.com/modelcontextprotocol/go-sdk/mcp"
19+
)
20+
21+
// rankedSimilarIssue is a single "Ranked Similar Issue" element returned by the
22+
// semantic-similarity endpoint. Only the issue fields the tool surfaces are
23+
// decoded, and Score is nullable because the API may omit a similarity score.
24+
type rankedSimilarIssue struct {
25+
Issue *struct {
26+
Number int `json:"number"`
27+
Title string `json:"title"`
28+
State string `json:"state"`
29+
HTMLURL string `json:"html_url"`
30+
} `json:"issue"`
31+
Score *float64 `json:"score"`
32+
Confidence string `json:"confidence"`
33+
LikelyDuplicate bool `json:"likely_duplicate"`
34+
}
35+
36+
// duplicateCandidate is the trimmed output for a ranked duplicate candidate,
37+
// carrying only what an agent needs to explain and act on it.
38+
type duplicateCandidate struct {
39+
Issue MinimalIssueRef `json:"issue"`
40+
Score *float64 `json:"score"`
41+
Confidence string `json:"confidence"`
42+
LikelyDuplicate bool `json:"likely_duplicate"`
43+
}
44+
45+
// FindDuplicate creates a read-only tool that returns ranked duplicate
46+
// candidates for an existing issue. It is a separate, feature-flagged tool so
47+
// duplicate detection is only advertised when explicitly opted in, keeping the
48+
// default tool surface small. The semantic ranking itself is owned by the API;
49+
// this tool only forwards the request and projects the ranked results.
50+
func FindDuplicate(t translations.TranslationHelperFunc) inventory.ServerTool {
51+
schema := &jsonschema.Schema{
52+
Type: "object",
53+
Properties: map[string]*jsonschema.Schema{
54+
"owner": {
55+
Type: "string",
56+
Description: "The owner of the repository",
57+
},
58+
"repo": {
59+
Type: "string",
60+
Description: "The name of the repository",
61+
},
62+
"issue_number": {
63+
Type: "number",
64+
Description: "The number of the existing issue to find duplicates for",
65+
},
66+
"confidence_threshold": {
67+
Type: "number",
68+
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.",
69+
},
70+
},
71+
Required: []string{"owner", "repo", "issue_number"},
72+
}
73+
WithPagination(schema)
74+
75+
st := NewTool(
76+
ToolsetMetadataIssues,
77+
mcp.Tool{
78+
Name: "find_duplicate",
79+
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."),
80+
Annotations: &mcp.ToolAnnotations{
81+
Title: t("TOOL_FIND_DUPLICATE_USER_TITLE", "Find duplicate issues"),
82+
ReadOnlyHint: true,
83+
},
84+
InputSchema: schema,
85+
},
86+
[]scopes.Scope{scopes.Repo},
87+
func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
88+
owner, err := RequiredParam[string](args, "owner")
89+
if err != nil {
90+
return utils.NewToolResultError(err.Error()), nil, nil
91+
}
92+
repo, err := RequiredParam[string](args, "repo")
93+
if err != nil {
94+
return utils.NewToolResultError(err.Error()), nil, nil
95+
}
96+
issueNumber, err := RequiredInt(args, "issue_number")
97+
if err != nil {
98+
return utils.NewToolResultError(err.Error()), nil, nil
99+
}
100+
101+
// Build the query preserving whether each optional value was supplied
102+
// so unset parameters fall back to the API's own defaults.
103+
query := url.Values{}
104+
if threshold, ok, err := OptionalParamOK[float64](args, "confidence_threshold"); err != nil {
105+
return utils.NewToolResultError(err.Error()), nil, nil
106+
} else if ok {
107+
query.Set("threshold", strconv.FormatFloat(threshold, 'g', -1, 64))
108+
}
109+
if _, ok := args["perPage"]; ok {
110+
perPage, err := OptionalIntParam(args, "perPage")
111+
if err != nil {
112+
return utils.NewToolResultError(err.Error()), nil, nil
113+
}
114+
query.Set("per_page", strconv.Itoa(perPage))
115+
}
116+
if _, ok := args["page"]; ok {
117+
page, err := OptionalIntParam(args, "page")
118+
if err != nil {
119+
return utils.NewToolResultError(err.Error()), nil, nil
120+
}
121+
query.Set("page", strconv.Itoa(page))
122+
}
123+
124+
client, err := deps.GetClient(ctx)
125+
if err != nil {
126+
return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil
127+
}
128+
129+
apiURL := fmt.Sprintf("repos/%s/%s/issues/%d/semantically_similar", owner, repo, issueNumber)
130+
if encoded := query.Encode(); encoded != "" {
131+
apiURL += "?" + encoded
132+
}
133+
134+
req, err := client.NewRequest(ctx, http.MethodGet, apiURL, nil)
135+
if err != nil {
136+
return utils.NewToolResultErrorFromErr("failed to create request", err), nil, nil
137+
}
138+
139+
var results []rankedSimilarIssue
140+
resp, err := client.Do(req, &results)
141+
if err != nil {
142+
return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to find duplicate issues", resp, err), nil, nil
143+
}
144+
defer func() { _ = resp.Body.Close() }()
145+
146+
candidates := make([]duplicateCandidate, 0, len(results))
147+
for _, res := range results {
148+
// A bare issue (no ranking metadata) means ranked duplicate
149+
// detection is not enabled for this caller; fail clearly rather
150+
// than returning incomplete candidates.
151+
if res.Confidence == "" || res.Issue == nil {
152+
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
153+
}
154+
candidates = append(candidates, duplicateCandidate{
155+
Issue: MinimalIssueRef{
156+
Number: res.Issue.Number,
157+
Title: res.Issue.Title,
158+
State: res.Issue.State,
159+
URL: res.Issue.HTMLURL,
160+
},
161+
Score: res.Score,
162+
Confidence: res.Confidence,
163+
LikelyDuplicate: res.LikelyDuplicate,
164+
})
165+
}
166+
167+
r, err := json.Marshal(candidates)
168+
if err != nil {
169+
return utils.NewToolResultErrorFromErr("failed to marshal duplicate candidates", err), nil, nil
170+
}
171+
172+
// Candidate issue titles are user-authored content scoped to the source
173+
// repository, so classify the result like issue_read.
174+
result := utils.NewToolResultText(string(r))
175+
result = attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, result, ifc.LabelRepoUserContent)
176+
return result, nil, nil
177+
})
178+
st.FeatureFlagEnable = FeatureFlagDuplicateDetection
179+
return st
180+
}

0 commit comments

Comments
 (0)