Skip to content

Commit d99a34b

Browse files
committed
Add ifc label for search_repositories tool
Emits an IFC SecurityLabel on the search_repositories tool result when the InsidersMode flag is enabled, mirroring the pattern landed for get_me (#2432), list_issues (#2453), get_file_contents (#2454), search_issues (#2456), and issue_read (#2457). Search results may span multiple repositories, so the join math (integrity always untrusted; private wins by intersecting collaborator sets across the matched private repos only) is shared with search_issues via ifc.LabelSearchIssues. Visibility is read directly off the search response's repo.Private field — no extra API call. Collaborators are fetched only for private hits, and any failure causes the label to be omitted entirely (consistent with search_issues / issue_read / get_file_contents). Refs github/copilot-mcp-core#1623, github/copilot-mcp-core#1389.
1 parent 883f58d commit d99a34b

2 files changed

Lines changed: 229 additions & 1 deletion

File tree

pkg/github/search.go

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"net/http"
99

1010
ghErrors "github.com/github/github-mcp-server/pkg/errors"
11+
"github.com/github/github-mcp-server/pkg/ifc"
1112
"github.com/github/github-mcp-server/pkg/inventory"
1213
"github.com/github/github-mcp-server/pkg/scopes"
1314
"github.com/github/github-mcp-server/pkg/translations"
@@ -161,11 +162,60 @@ func SearchRepositories(t translations.TranslationHelperFunc) inventory.ServerTo
161162
}
162163
}
163164

164-
return utils.NewToolResultText(string(r)), nil, nil
165+
callResult := utils.NewToolResultText(string(r))
166+
if deps.GetFlags(ctx).InsidersMode {
167+
attachSearchRepositoriesIFCLabel(ctx, client, result.Repositories, callResult)
168+
}
169+
return callResult, nil, nil
165170
},
166171
)
167172
}
168173

174+
// attachSearchRepositoriesIFCLabel joins per-repository IFC labels across
175+
// every matched repository and attaches the result to callResult. Visibility
176+
// is read directly from the search response (no extra API call); collaborators
177+
// are fetched once per private repository. If any collaborators lookup fails
178+
// the label is omitted to avoid misclassifying the result. The join math is
179+
// shared with search_issues via ifc.LabelSearchIssues: integrity is always
180+
// untrusted, and confidentiality is the intersection of the reader sets of
181+
// the matched private repositories (public matches contribute the universe
182+
// set and drop out without shrinking it).
183+
func attachSearchRepositoriesIFCLabel(ctx context.Context, client *github.Client, repos []*github.Repository, callResult *mcp.CallToolResult) {
184+
if callResult == nil || callResult.IsError {
185+
return
186+
}
187+
188+
visibilities := make([]bool, 0, len(repos))
189+
readerSets := make([][]string, 0, len(repos))
190+
for _, repo := range repos {
191+
isPrivate := repo.GetPrivate()
192+
visibilities = append(visibilities, isPrivate)
193+
if !isPrivate {
194+
readerSets = append(readerSets, nil)
195+
continue
196+
}
197+
owner := repo.GetOwner().GetLogin()
198+
name := repo.GetName()
199+
if owner == "" || name == "" {
200+
return
201+
}
202+
collaborators, err := FetchRepoCollaborators(ctx, client, owner, name)
203+
if err != nil {
204+
return
205+
}
206+
readerSets = append(readerSets, collaborators)
207+
}
208+
209+
label, ok := ifc.LabelSearchIssues(visibilities, readerSets)
210+
if !ok {
211+
return
212+
}
213+
if callResult.Meta == nil {
214+
callResult.Meta = mcp.Meta{}
215+
}
216+
callResult.Meta["ifc"] = label
217+
}
218+
169219
// SearchCode creates a tool to search for code across GitHub repositories.
170220
func SearchCode(t translations.TranslationHelperFunc) inventory.ServerTool {
171221
schema := &jsonschema.Schema{

pkg/github/search_test.go

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,9 +163,187 @@ func Test_SearchRepositories(t *testing.T) {
163163
assert.Equal(t, *tc.expectedResult.Repositories[i].FullName, repo.FullName)
164164
assert.Equal(t, *tc.expectedResult.Repositories[i].HTMLURL, repo.HTMLURL)
165165
}
166+
})
167+
}
168+
}
169+
170+
func Test_SearchRepositories_IFC_InsidersMode(t *testing.T) {
171+
t.Parallel()
166172

173+
serverTool := SearchRepositories(translations.NullTranslationHelper)
174+
175+
type repoFixture struct {
176+
owner string
177+
name string
178+
isPrivate bool
179+
collaborators []string
180+
collaboratorsStatus int
181+
}
182+
183+
makeRepo := func(r repoFixture) *github.Repository {
184+
return &github.Repository{
185+
ID: github.Ptr(int64(1)),
186+
Name: github.Ptr(r.name),
187+
FullName: github.Ptr(r.owner + "/" + r.name),
188+
Private: github.Ptr(r.isPrivate),
189+
Owner: &github.User{Login: github.Ptr(r.owner)},
190+
}
191+
}
192+
193+
makeMockClient := func(repos []repoFixture) *http.Client {
194+
searchResult := &github.RepositoriesSearchResult{
195+
Total: github.Ptr(len(repos)),
196+
IncompleteResults: github.Ptr(false),
197+
}
198+
for _, r := range repos {
199+
searchResult.Repositories = append(searchResult.Repositories, makeRepo(r))
200+
}
201+
202+
collaboratorsByPath := map[string]repoFixture{}
203+
for _, r := range repos {
204+
collaboratorsByPath["/repos/"+r.owner+"/"+r.name+"/collaborators"] = r
205+
}
206+
207+
return MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
208+
GetSearchRepositories: mockResponse(t, http.StatusOK, searchResult),
209+
GetReposCollaboratorsByOwnerByRepo: func(w http.ResponseWriter, req *http.Request) {
210+
r, ok := collaboratorsByPath[req.URL.Path]
211+
if !ok {
212+
w.WriteHeader(http.StatusOK)
213+
_, _ = w.Write([]byte("[]"))
214+
return
215+
}
216+
if r.collaboratorsStatus != 0 && r.collaboratorsStatus != http.StatusOK {
217+
w.WriteHeader(r.collaboratorsStatus)
218+
return
219+
}
220+
users := make([]*github.User, len(r.collaborators))
221+
for i, login := range r.collaborators {
222+
users[i] = &github.User{Login: github.Ptr(login)}
223+
}
224+
body, _ := json.Marshal(users)
225+
w.WriteHeader(http.StatusOK)
226+
_, _ = w.Write(body)
227+
},
167228
})
168229
}
230+
231+
reqParams := map[string]any{"query": "octocat"}
232+
233+
t.Run("insiders mode disabled omits ifc label", func(t *testing.T) {
234+
deps := BaseDeps{
235+
Client: github.NewClient(makeMockClient([]repoFixture{{owner: "octocat", name: "public-repo"}})),
236+
Flags: FeatureFlags{InsidersMode: false},
237+
}
238+
handler := serverTool.Handler(deps)
239+
240+
request := createMCPRequest(reqParams)
241+
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
242+
require.NoError(t, err)
243+
require.False(t, result.IsError)
244+
assert.Nil(t, result.Meta)
245+
})
246+
247+
t.Run("insiders mode all public emits public untrusted", func(t *testing.T) {
248+
deps := BaseDeps{
249+
Client: github.NewClient(makeMockClient([]repoFixture{
250+
{owner: "octocat", name: "public-a"},
251+
{owner: "octocat", name: "public-b"},
252+
})),
253+
Flags: FeatureFlags{InsidersMode: true},
254+
}
255+
handler := serverTool.Handler(deps)
256+
257+
request := createMCPRequest(reqParams)
258+
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
259+
require.NoError(t, err)
260+
require.False(t, result.IsError)
261+
262+
require.NotNil(t, result.Meta)
263+
ifcMap := unmarshalIFC(t, result.Meta["ifc"])
264+
assert.Equal(t, "untrusted", ifcMap["integrity"])
265+
assert.Equal(t, []any{"public"}, ifcMap["confidentiality"])
266+
})
267+
268+
t.Run("insiders mode mixed public and private keeps the private readers", func(t *testing.T) {
269+
deps := BaseDeps{
270+
Client: github.NewClient(makeMockClient([]repoFixture{
271+
{owner: "octocat", name: "private-repo", isPrivate: true, collaborators: []string{"alice"}},
272+
{owner: "octocat", name: "public-repo"},
273+
})),
274+
Flags: FeatureFlags{InsidersMode: true},
275+
}
276+
handler := serverTool.Handler(deps)
277+
278+
request := createMCPRequest(reqParams)
279+
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
280+
require.NoError(t, err)
281+
require.False(t, result.IsError)
282+
283+
require.NotNil(t, result.Meta)
284+
ifcMap := unmarshalIFC(t, result.Meta["ifc"])
285+
assert.Equal(t, "untrusted", ifcMap["integrity"])
286+
assert.Equal(t, []any{"alice"}, ifcMap["confidentiality"])
287+
})
288+
289+
t.Run("insiders mode two private repos intersect collaborators", func(t *testing.T) {
290+
deps := BaseDeps{
291+
Client: github.NewClient(makeMockClient([]repoFixture{
292+
{owner: "octocat", name: "repo-a", isPrivate: true, collaborators: []string{"alice", "bob", "carol"}},
293+
{owner: "octocat", name: "repo-b", isPrivate: true, collaborators: []string{"bob", "carol", "dan"}},
294+
})),
295+
Flags: FeatureFlags{InsidersMode: true},
296+
}
297+
handler := serverTool.Handler(deps)
298+
299+
request := createMCPRequest(reqParams)
300+
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
301+
require.NoError(t, err)
302+
require.False(t, result.IsError)
303+
304+
require.NotNil(t, result.Meta)
305+
ifcMap := unmarshalIFC(t, result.Meta["ifc"])
306+
assert.Equal(t, "untrusted", ifcMap["integrity"])
307+
assert.Equal(t, []any{"bob", "carol"}, ifcMap["confidentiality"])
308+
})
309+
310+
t.Run("insiders mode skips ifc label when collaborators lookup fails", func(t *testing.T) {
311+
deps := BaseDeps{
312+
Client: github.NewClient(makeMockClient([]repoFixture{
313+
{owner: "octocat", name: "private-repo", isPrivate: true, collaboratorsStatus: http.StatusInternalServerError},
314+
})),
315+
Flags: FeatureFlags{InsidersMode: true},
316+
}
317+
handler := serverTool.Handler(deps)
318+
319+
request := createMCPRequest(reqParams)
320+
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
321+
require.NoError(t, err)
322+
require.False(t, result.IsError, "tool call should still succeed when collaborators lookup fails")
323+
324+
if result.Meta != nil {
325+
_, hasIFC := result.Meta["ifc"]
326+
assert.False(t, hasIFC, "ifc label should be omitted when collaborators lookup fails")
327+
}
328+
})
329+
330+
t.Run("insiders mode empty results emits public untrusted", func(t *testing.T) {
331+
deps := BaseDeps{
332+
Client: github.NewClient(makeMockClient(nil)),
333+
Flags: FeatureFlags{InsidersMode: true},
334+
}
335+
handler := serverTool.Handler(deps)
336+
337+
request := createMCPRequest(reqParams)
338+
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
339+
require.NoError(t, err)
340+
require.False(t, result.IsError)
341+
342+
require.NotNil(t, result.Meta)
343+
ifcMap := unmarshalIFC(t, result.Meta["ifc"])
344+
assert.Equal(t, "untrusted", ifcMap["integrity"])
345+
assert.Equal(t, []any{"public"}, ifcMap["confidentiality"])
346+
})
169347
}
170348

171349
func Test_SearchRepositories_FullOutput(t *testing.T) {

0 commit comments

Comments
 (0)