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
60 changes: 53 additions & 7 deletions pkg/github/repositories.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"strings"

ghErrors "github.com/github/github-mcp-server/pkg/errors"
"github.com/github/github-mcp-server/pkg/ifc"
"github.com/github/github-mcp-server/pkg/inventory"
"github.com/github/github-mcp-server/pkg/octicons"
"github.com/github/github-mcp-server/pkg/scopes"
Expand Down Expand Up @@ -653,6 +654,17 @@ func CreateRepository(t translations.TranslationHelperFunc) inventory.ServerTool
)
}

// FetchRepoIsPrivate returns the visibility of a repository. It is a thin
// wrapper around the GitHub Repositories.Get endpoint provided as a shared
// helper for IFC label computation across tools.
func FetchRepoIsPrivate(ctx context.Context, client *github.Client, owner, repo string) (bool, error) {
r, _, err := client.Repositories.Get(ctx, owner, repo)
if err != nil {
return false, err
}
return r.GetPrivate(), nil
Comment on lines +660 to +665
}

// GetFileContents creates a tool to get the contents of a file or directory from a GitHub repository.
func GetFileContents(t translations.TranslationHelperFunc) inventory.ServerTool {
return NewTool(
Expand Down Expand Up @@ -725,6 +737,39 @@ func GetFileContents(t translations.TranslationHelperFunc) inventory.ServerTool
return utils.NewToolResultError("failed to get GitHub client"), nil, nil
}

// attachIFC adds the IFC label to a successful tool result when
// InsidersMode is enabled. The visibility lookup is performed
// lazily on first use and is best-effort: if it fails we skip
// the label rather than fail the user-facing call.
var (
ifcVisibilityLoaded bool
ifcIsPrivate bool
)
attachIFC := func(r *mcp.CallToolResult) *mcp.CallToolResult {
if r == nil || r.IsError || !deps.GetFlags(ctx).InsidersMode {
return r
}
if !ifcVisibilityLoaded {
if isPrivate, err := FetchRepoIsPrivate(ctx, client, owner, repo); err == nil {
ifcIsPrivate = isPrivate
}
ifcVisibilityLoaded = true
Comment on lines +745 to +756
}
if r.Meta == nil {
r.Meta = mcp.Meta{}
}
// TODO(fides): for private repos, populate readers with the
// repository's collaborator logins. Using [owner] as a
// placeholder until a shared visibility/collaborators helper
// lands (tracked under copilot-mcp-core#1623).
var readers []string
if ifcIsPrivate {
readers = []string{owner}
}
r.Meta["ifc"] = ifc.LabelGetFileContents(ifcIsPrivate, readers)
return r
}

rawOpts, fallbackUsed, err := resolveGitReference(ctx, client, owner, repo, ref, sha)
if err != nil {
return utils.NewToolResultError(fmt.Sprintf("failed to resolve git reference: %s", err)), nil, nil
Expand All @@ -746,7 +791,8 @@ func GetFileContents(t translations.TranslationHelperFunc) inventory.ServerTool
// The path does not point to a file or directory.
// Instead let's try to find it in the Git Tree by matching the end of the path.
if err != nil || (fileContent == nil && dirContent == nil) {
return matchFiles(ctx, client, owner, repo, ref, path, rawOpts, 0)
res, data, err := matchFiles(ctx, client, owner, repo, ref, path, rawOpts, 0)
return attachIFC(res), data, err
}

if fileContent != nil && fileContent.SHA != nil {
Expand Down Expand Up @@ -776,7 +822,7 @@ func GetFileContents(t translations.TranslationHelperFunc) inventory.ServerTool
Text: "",
MIMEType: "text/plain",
}
return utils.NewToolResultResource(fmt.Sprintf("successfully downloaded empty file (SHA: %s)%s", fileSHA, successNote), result), nil, nil
return attachIFC(utils.NewToolResultResource(fmt.Sprintf("successfully downloaded empty file (SHA: %s)%s", fileSHA, successNote), result)), nil, nil
}

// For files >= 1MB, return a ResourceLink instead of content
Expand All @@ -789,10 +835,10 @@ func GetFileContents(t translations.TranslationHelperFunc) inventory.ServerTool
Title: fmt.Sprintf("File: %s", path),
Size: &size,
}
return utils.NewToolResultResourceLink(
return attachIFC(utils.NewToolResultResourceLink(
fmt.Sprintf("File %s is too large to display (%d bytes). Use the download URL to fetch the content: %s (SHA: %s)%s",
path, fileSize, fileContent.GetDownloadURL(), fileSHA, successNote),
resourceLink), nil, nil
resourceLink)), nil, nil
}

// For files < 1MB, get content directly from Contents API
Expand Down Expand Up @@ -820,7 +866,7 @@ func GetFileContents(t translations.TranslationHelperFunc) inventory.ServerTool
Text: content,
MIMEType: contentType,
}
return utils.NewToolResultResource(fmt.Sprintf("successfully downloaded text file (SHA: %s)%s", fileSHA, successNote), result), nil, nil
return attachIFC(utils.NewToolResultResource(fmt.Sprintf("successfully downloaded text file (SHA: %s)%s", fileSHA, successNote), result)), nil, nil
}

// Binary content - encode as base64 blob
Expand All @@ -830,14 +876,14 @@ func GetFileContents(t translations.TranslationHelperFunc) inventory.ServerTool
Blob: []byte(blobContent),
MIMEType: contentType,
}
return utils.NewToolResultResource(fmt.Sprintf("successfully downloaded binary file (SHA: %s)%s", fileSHA, successNote), result), nil, nil
return attachIFC(utils.NewToolResultResource(fmt.Sprintf("successfully downloaded binary file (SHA: %s)%s", fileSHA, successNote), result)), nil, nil
} else if dirContent != nil {
// file content or file SHA is nil which means it's a directory
r, err := json.Marshal(dirContent)
if err != nil {
return utils.NewToolResultError("failed to marshal response"), nil, nil
}
return utils.NewToolResultText(string(r)), nil, nil
return attachIFC(utils.NewToolResultText(string(r))), nil, nil
}

return utils.NewToolResultError("failed to get file contents"), nil, nil
Expand Down
112 changes: 112 additions & 0 deletions pkg/github/repositories_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -477,6 +477,118 @@ func Test_GetFileContents(t *testing.T) {
}
}

func Test_GetFileContents_IFC_InsidersMode(t *testing.T) {
t.Parallel()

serverTool := GetFileContents(translations.NullTranslationHelper)

mockRawContent := []byte("hello")

makeMockClient := func(isPrivate bool) *http.Client {
return MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetReposGitRefByOwnerByRepoByRef: mockResponse(t, http.StatusOK, "{\"ref\": \"refs/heads/main\", \"object\": {\"sha\": \"\"}}"),
GetReposByOwnerByRepo: mockResponse(t, http.StatusOK, map[string]any{
"name": "repo",
"default_branch": "main",
"private": isPrivate,
}),
Comment on lines +487 to +494
GetReposContentsByOwnerByRepoByPath: func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
encodedContent := base64.StdEncoding.EncodeToString(mockRawContent)
fileContent := &github.RepositoryContent{
Name: github.Ptr("README.md"),
Path: github.Ptr("README.md"),
SHA: github.Ptr("abc123"),
Type: github.Ptr("file"),
Content: github.Ptr(encodedContent),
Size: github.Ptr(len(mockRawContent)),
Encoding: github.Ptr("base64"),
}
contentBytes, _ := json.Marshal(fileContent)
_, _ = w.Write(contentBytes)
},
})
}

reqParams := map[string]any{
"owner": "octocat",
"repo": "repo",
"path": "README.md",
"ref": "refs/heads/main",
}

t.Run("insiders mode disabled omits ifc label from result meta", func(t *testing.T) {
deps := BaseDeps{
Client: github.NewClient(makeMockClient(false)),
Flags: FeatureFlags{InsidersMode: false},
}
handler := serverTool.Handler(deps)

request := createMCPRequest(reqParams)
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
require.False(t, result.IsError)

assert.Nil(t, result.Meta, "result meta should be nil when insiders mode is disabled")
})

t.Run("insiders mode enabled on public repo emits public untrusted label", func(t *testing.T) {
deps := BaseDeps{
Client: github.NewClient(makeMockClient(false)),
Flags: FeatureFlags{InsidersMode: true},
}
handler := serverTool.Handler(deps)

request := createMCPRequest(reqParams)
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
require.False(t, result.IsError)

require.NotNil(t, result.Meta)
ifcLabel, ok := result.Meta["ifc"]
require.True(t, ok, "result meta should contain ifc key")

ifcJSON, err := json.Marshal(ifcLabel)
require.NoError(t, err)
var ifcMap map[string]any
require.NoError(t, json.Unmarshal(ifcJSON, &ifcMap))

assert.Equal(t, "untrusted", ifcMap["integrity"])
confList, ok := ifcMap["confidentiality"].([]any)
require.True(t, ok, "confidentiality should be a list")
require.Len(t, confList, 1)
assert.Equal(t, "public", confList[0])
})

t.Run("insiders mode enabled on private repo emits private trusted label", func(t *testing.T) {
deps := BaseDeps{
Client: github.NewClient(makeMockClient(true)),
Flags: FeatureFlags{InsidersMode: true},
}
handler := serverTool.Handler(deps)

request := createMCPRequest(reqParams)
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
require.False(t, result.IsError)

require.NotNil(t, result.Meta)
ifcLabel, ok := result.Meta["ifc"]
require.True(t, ok, "result meta should contain ifc key")

ifcJSON, err := json.Marshal(ifcLabel)
require.NoError(t, err)
var ifcMap map[string]any
require.NoError(t, json.Unmarshal(ifcJSON, &ifcMap))

assert.Equal(t, "trusted", ifcMap["integrity"])
confList, ok := ifcMap["confidentiality"].([]any)
require.True(t, ok, "confidentiality should be a list")
require.Len(t, confList, 1)
assert.Equal(t, "octocat", confList[0])
})
}

func Test_ForkRepository(t *testing.T) {
// Verify tool definition once
serverTool := ForkRepository(translations.NullTranslationHelper)
Expand Down
11 changes: 11 additions & 0 deletions pkg/ifc/ifc.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,3 +75,14 @@ func LabelListIssues(isPrivate bool, readers []string) SecurityLabel {
}
return PublicUntrusted()
}

// LabelGetFileContents returns the IFC label for a get_file_contents result.
// Public repository file contents may be authored by anyone via pull requests
// and are therefore untrusted. In private repositories only collaborators can
// land changes, so contents are treated as trusted.
func LabelGetFileContents(isPrivate bool, readers []string) SecurityLabel {
if isPrivate {
return PrivateTrusted(readers)
}
return PublicUntrusted()
}
Loading