Skip to content

Commit 320800e

Browse files
Add non-default find_duplicate tool gated by duplicate_detection flag
1 parent 3778a41 commit 320800e

6 files changed

Lines changed: 466 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 for a candidate to be returned. When omitted, the API's high-precision default is used. (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: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
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 for a candidate to be returned. When omitted, the API's high-precision default is used.",
12+
"maximum": 1,
13+
"minimum": 0,
14+
"type": "number"
15+
},
16+
"issue_number": {
17+
"description": "The number of the existing issue to find duplicates for",
18+
"type": "number"
19+
},
20+
"owner": {
21+
"description": "The owner of the repository",
22+
"type": "string"
23+
},
24+
"page": {
25+
"description": "Page number for pagination (min 1)",
26+
"minimum": 1,
27+
"type": "number"
28+
},
29+
"perPage": {
30+
"description": "Results per page for pagination (min 1, max 100)",
31+
"maximum": 100,
32+
"minimum": 1,
33+
"type": "number"
34+
},
35+
"repo": {
36+
"description": "The name of the repository",
37+
"type": "string"
38+
}
39+
},
40+
"required": [
41+
"owner",
42+
"repo",
43+
"issue_number"
44+
],
45+
"type": "object"
46+
},
47+
"name": "find_duplicate"
48+
}

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: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
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/inventory"
13+
"github.com/github/github-mcp-server/pkg/scopes"
14+
"github.com/github/github-mcp-server/pkg/translations"
15+
"github.com/github/github-mcp-server/pkg/utils"
16+
"github.com/google/jsonschema-go/jsonschema"
17+
"github.com/modelcontextprotocol/go-sdk/mcp"
18+
)
19+
20+
// duplicateIssueResult mirrors a single "Ranked Similar Issue" element returned
21+
// by the semantic-similarity endpoint. Issue is kept as raw JSON so the full
22+
// issue representation is preserved verbatim, and Score is nullable because the
23+
// API may omit a similarity score.
24+
type duplicateIssueResult struct {
25+
Issue json.RawMessage `json:"issue"`
26+
Score *float64 `json:"score"`
27+
Confidence string `json:"confidence"`
28+
LikelyDuplicate bool `json:"likely_duplicate"`
29+
}
30+
31+
// FindDuplicate creates a read-only tool that returns ranked duplicate
32+
// candidates for an existing issue. It is a separate, feature-flagged tool so
33+
// duplicate detection is only advertised when explicitly opted in, keeping the
34+
// default tool surface small. The semantic ranking itself is owned by the API;
35+
// this tool only forwards the request and projects the ranked results.
36+
func FindDuplicate(t translations.TranslationHelperFunc) inventory.ServerTool {
37+
schema := &jsonschema.Schema{
38+
Type: "object",
39+
Properties: map[string]*jsonschema.Schema{
40+
"owner": {
41+
Type: "string",
42+
Description: "The owner of the repository",
43+
},
44+
"repo": {
45+
Type: "string",
46+
Description: "The name of the repository",
47+
},
48+
"issue_number": {
49+
Type: "number",
50+
Description: "The number of the existing issue to find duplicates for",
51+
},
52+
"confidence_threshold": {
53+
Type: "number",
54+
Description: "Minimum similarity threshold for a candidate to be returned. When omitted, the API's high-precision default is used.",
55+
Minimum: jsonschema.Ptr(0.0),
56+
Maximum: jsonschema.Ptr(1.0),
57+
},
58+
},
59+
Required: []string{"owner", "repo", "issue_number"},
60+
}
61+
WithPagination(schema)
62+
63+
st := NewTool(
64+
ToolsetMetadataIssues,
65+
mcp.Tool{
66+
Name: "find_duplicate",
67+
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."),
68+
Annotations: &mcp.ToolAnnotations{
69+
Title: t("TOOL_FIND_DUPLICATE_USER_TITLE", "Find duplicate issues"),
70+
ReadOnlyHint: true,
71+
},
72+
InputSchema: schema,
73+
},
74+
[]scopes.Scope{scopes.Repo},
75+
func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
76+
owner, err := RequiredParam[string](args, "owner")
77+
if err != nil {
78+
return utils.NewToolResultError(err.Error()), nil, nil
79+
}
80+
repo, err := RequiredParam[string](args, "repo")
81+
if err != nil {
82+
return utils.NewToolResultError(err.Error()), nil, nil
83+
}
84+
issueNumber, err := RequiredInt(args, "issue_number")
85+
if err != nil {
86+
return utils.NewToolResultError(err.Error()), nil, nil
87+
}
88+
89+
// Build the query preserving whether each optional value was supplied
90+
// so unset parameters fall back to the API's own defaults.
91+
query := url.Values{}
92+
if threshold, ok, err := OptionalParamOK[float64](args, "confidence_threshold"); err != nil {
93+
return utils.NewToolResultError(err.Error()), nil, nil
94+
} else if ok {
95+
query.Set("threshold", strconv.FormatFloat(threshold, 'g', -1, 64))
96+
}
97+
if _, ok := args["perPage"]; ok {
98+
perPage, err := OptionalIntParam(args, "perPage")
99+
if err != nil {
100+
return utils.NewToolResultError(err.Error()), nil, nil
101+
}
102+
query.Set("per_page", strconv.Itoa(perPage))
103+
}
104+
if _, ok := args["page"]; ok {
105+
page, err := OptionalIntParam(args, "page")
106+
if err != nil {
107+
return utils.NewToolResultError(err.Error()), nil, nil
108+
}
109+
query.Set("page", strconv.Itoa(page))
110+
}
111+
112+
client, err := deps.GetClient(ctx)
113+
if err != nil {
114+
return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil
115+
}
116+
117+
apiURL := fmt.Sprintf("repos/%s/%s/issues/%d/semantically_similar", owner, repo, issueNumber)
118+
if encoded := query.Encode(); encoded != "" {
119+
apiURL += "?" + encoded
120+
}
121+
122+
req, err := client.NewRequest(ctx, http.MethodGet, apiURL, nil)
123+
if err != nil {
124+
return utils.NewToolResultErrorFromErr("failed to create request", err), nil, nil
125+
}
126+
127+
var results []duplicateIssueResult
128+
resp, err := client.Do(req, &results)
129+
if err != nil {
130+
return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to find duplicate issues", resp, err), nil, nil
131+
}
132+
defer func() { _ = resp.Body.Close() }()
133+
134+
// When ranked duplicate detection is not enabled for the caller, the
135+
// endpoint returns bare issue resources instead of ranked results.
136+
// Surface that as an explicit error rather than incomplete candidates.
137+
for i := range results {
138+
if results[i].Confidence == "" || len(results[i].Issue) == 0 {
139+
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
140+
}
141+
}
142+
143+
r, err := json.Marshal(results)
144+
if err != nil {
145+
return utils.NewToolResultErrorFromErr("failed to marshal duplicate candidates", err), nil, nil
146+
}
147+
148+
return utils.NewToolResultText(string(r)), nil, nil
149+
})
150+
st.FeatureFlagEnable = FeatureFlagDuplicateDetection
151+
return st
152+
}

0 commit comments

Comments
 (0)