From d331b8d683a7db02a23e4c6e18500f03879ba87d Mon Sep 17 00:00:00 2001 From: root Date: Sat, 6 Jun 2026 22:22:07 +0000 Subject: [PATCH] Add repository focus mode for project-scoped MCP workflows Bind a stdio server instance to a default owner/repo so agents can call get_repository_context instead of searching repositories first, with optional discovery-tool filtering and fine-grained PAT access hints. Closes #1683 --- README.md | 3 + cmd/github-mcp-server/main.go | 6 + docs/server-configuration.md | 46 +++++ internal/ghmcp/server.go | 46 +++++ pkg/github/context_tools_test.go | 1 + pkg/github/dependencies.go | 25 +++ pkg/github/dependencies_test.go | 4 + pkg/github/feature_flags_test.go | 1 + pkg/github/repository_context.go | 272 ++++++++++++++++++++++++++ pkg/github/repository_context_test.go | 172 ++++++++++++++++ pkg/github/server.go | 9 + pkg/github/server_test.go | 1 + pkg/github/tools.go | 1 + pkg/github/toolset_instructions.go | 19 +- pkg/inventory/builder.go | 17 ++ pkg/inventory/instructions.go | 11 ++ pkg/inventory/instructions_test.go | 24 +++ pkg/inventory/registry.go | 14 ++ 18 files changed, 670 insertions(+), 2 deletions(-) create mode 100644 pkg/github/repository_context.go create mode 100644 pkg/github/repository_context_test.go diff --git a/README.md b/README.md index dff62321b8..692c2cbde8 100644 --- a/README.md +++ b/README.md @@ -670,6 +670,9 @@ The following sets of tools are available: - **get_me** - Get my user profile - No parameters required +- **get_repository_context** - Get repository context + - No parameters required + - **get_team_members** - Get team members - **Required OAuth Scopes**: `read:org` - **Accepted OAuth Scopes**: `admin:org`, `read:org`, `write:org` diff --git a/cmd/github-mcp-server/main.go b/cmd/github-mcp-server/main.go index 558fdb9980..cf7063e6a7 100644 --- a/cmd/github-mcp-server/main.go +++ b/cmd/github-mcp-server/main.go @@ -94,6 +94,8 @@ var ( InsidersMode: viper.GetBool("insiders"), ExcludeTools: excludeTools, RepoAccessCacheTTL: &ttl, + DefaultRepository: viper.GetString("repository"), + AllowDiscoveryTools: viper.GetBool("allow-discovery-tools"), } return ghmcp.RunStdioServer(stdioServerConfig) }, @@ -181,6 +183,8 @@ func init() { rootCmd.PersistentFlags().Bool("lockdown-mode", false, "Enable lockdown mode") rootCmd.PersistentFlags().Bool("insiders", false, "Enable insiders features") rootCmd.PersistentFlags().Duration("repo-access-cache-ttl", 5*time.Minute, "Override the repo access cache TTL (e.g. 1m, 0s to disable)") + rootCmd.PersistentFlags().String("repository", "", "Default owner/repo for project-focused mode (also GITHUB_REPOSITORY env var)") + rootCmd.PersistentFlags().Bool("allow-discovery-tools", false, "Keep open-world discovery tools when --repository is set") // HTTP-specific flags httpCmd.Flags().Int("port", 8082, "HTTP server port") @@ -203,6 +207,8 @@ func init() { _ = viper.BindPFlag("lockdown-mode", rootCmd.PersistentFlags().Lookup("lockdown-mode")) _ = viper.BindPFlag("insiders", rootCmd.PersistentFlags().Lookup("insiders")) _ = viper.BindPFlag("repo-access-cache-ttl", rootCmd.PersistentFlags().Lookup("repo-access-cache-ttl")) + _ = viper.BindPFlag("repository", rootCmd.PersistentFlags().Lookup("repository")) + _ = viper.BindPFlag("allow-discovery-tools", rootCmd.PersistentFlags().Lookup("allow-discovery-tools")) _ = viper.BindPFlag("port", httpCmd.Flags().Lookup("port")) _ = viper.BindPFlag("base-url", httpCmd.Flags().Lookup("base-url")) _ = viper.BindPFlag("base-path", httpCmd.Flags().Lookup("base-path")) diff --git a/docs/server-configuration.md b/docs/server-configuration.md index 2342664c3a..cb6dd54c1e 100644 --- a/docs/server-configuration.md +++ b/docs/server-configuration.md @@ -15,6 +15,7 @@ We currently support the following ways in which the GitHub MCP Server can be co | Insiders Mode | `X-MCP-Insiders` header or `/insiders` URL | `--insiders` flag or `GITHUB_INSIDERS` env var | | Feature Flags | `X-MCP-Features` header | `--features` flag | | Scope Filtering | Always enabled | Always enabled | +| Default Repository | Not available | `--repository` flag or `GITHUB_REPOSITORY` env var | | Server Name/Title | Not available | `GITHUB_MCP_SERVER_NAME` / `GITHUB_MCP_SERVER_TITLE` env vars or `github-mcp-server-config.json` | > **Default behavior:** If you don't specify any configuration, the server uses the **default toolsets**: `context`, `issues`, `pull_requests`, `repos`, `users`. @@ -446,6 +447,49 @@ MCP Apps is enabled by [Insiders Mode](#insiders-mode), or independently via the --- +### Repository Focus Mode + +**Best for:** single-repository development workflows where agents should behave like `gh` inside a git checkout—working directly on one project instead of searching across your account first. + +Set a default repository with `--repository owner/repo` or the `GITHUB_REPOSITORY` environment variable. The value accepts common formats such as `owner/repo`, `https://github.com/owner/repo`, or `git@github.com:owner/repo.git`. + +When a default repository is configured: + +- The `get_repository_context` tool returns the configured owner/repo and verifies token access (including fine-grained PAT permission hints when access fails). +- Server instructions tell agents to call `get_repository_context` first and use the configured owner/repo with repo-scoped tools like `list_issues`. +- Open-world discovery tools (`search_repositories`, `search_users`, `search_orgs`, `list_starred_repositories`, `create_repository`, `fork_repository`) are hidden unless you pass `--allow-discovery-tools`. + +**Example (local server):** + +```json +{ + "type": "stdio", + "command": "docker", + "args": [ + "run", + "-i", + "--rm", + "-e", + "GITHUB_PERSONAL_ACCESS_TOKEN", + "-e", + "GITHUB_REPOSITORY", + "ghcr.io/github/github-mcp-server", + "stdio", + "--read-only", + "--toolsets", + "context,issues,pull_requests" + ], + "env": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "${input:github_token}", + "GITHUB_REPOSITORY": "owner/repo" + } +} +``` + +> **Note:** The MCP server cannot read your local `.git` directory directly. Configure `GITHUB_REPOSITORY` in your MCP host settings (or pass `--repository`) to bind an instance to the project you are working on. + +--- + ### Scope Filtering **Automatic feature:** The server handles OAuth scopes differently depending on authentication type: @@ -467,6 +511,8 @@ See [Scope Filtering](./scope-filtering.md) for details on how filtering works w | Server fails to start | Invalid tool name in `--tools` or `X-MCP-Tools` | Check tool name spelling; use exact names from [Tools list](../README.md#tools) | | Write tools not working | Read-only mode enabled | Remove `--read-only` flag or `X-MCP-Readonly` header | | Tools missing | Toolset not enabled | Add the required toolset or specific tool | +| Agent searches all repos first | No default repository configured | Set `GITHUB_REPOSITORY` or `--repository owner/repo` and call `get_repository_context` | +| Fine-grained PAT cannot access collaborator repo | Token not authorized for that repository | Grant repository access and required permissions on the fine-grained PAT; check `get_repository_context` hint | --- diff --git a/internal/ghmcp/server.go b/internal/ghmcp/server.go index a37c4d940d..ed3d8283fc 100644 --- a/internal/ghmcp/server.go +++ b/internal/ghmcp/server.go @@ -135,6 +135,10 @@ func NewStdioMCPServer(ctx context.Context, cfg github.MCPServerConfig) (*mcp.Se if err != nil { return nil, fmt.Errorf("failed to create observability exporters: %w", err) } + repositoryContext, err := github.BuildRepositoryContextConfig(cfg.DefaultRepository, cfg.Token, cfg.AllowDiscoveryTools) + if err != nil { + return nil, fmt.Errorf("failed to parse default repository: %w", err) + } deps := github.NewBaseDeps( clients.rest, clients.gql, @@ -146,8 +150,36 @@ func NewStdioMCPServer(ctx context.Context, cfg github.MCPServerConfig) (*mcp.Se }, cfg.ContentWindowSize, featureChecker, + repositoryContext, obs, ) + if repositoryContext.DefaultRepository != nil { + access := github.VerifyRepositoryAccess( + ctx, + clients.rest, + *repositoryContext.DefaultRepository, + github.DetectTokenType(cfg.Token), + ) + if access.Accessible { + cfg.Logger.Info( + "default repository access verified", + "repository", + repositoryContext.DefaultRepository.FullName, + "private", + access.Private, + ) + } else { + cfg.Logger.Warn( + "default repository is not accessible with the current token", + "repository", + repositoryContext.DefaultRepository.FullName, + "error", + access.Error, + "hint", + access.Hint, + ) + } + } // Build and register the tool/resource/prompt inventory inventoryBuilder := github.NewInventory(cfg.Translator). WithDeprecatedAliases(github.DeprecatedToolAliases). @@ -156,8 +188,14 @@ func NewStdioMCPServer(ctx context.Context, cfg github.MCPServerConfig) (*mcp.Se WithTools(github.CleanTools(cfg.EnabledTools)). WithExcludeTools(cfg.ExcludeTools). WithServerInstructions(). + WithDefaultRepository(cfg.DefaultRepository). + WithFocusRepository(repositoryContext.FocusMode). WithFeatureChecker(featureChecker) + if repositoryContext.FocusMode { + inventoryBuilder = inventoryBuilder.WithFilter(github.CreateRepositoryFocusFilter(true)) + } + // Apply token scope filtering if scopes are known (for PAT filtering) if cfg.TokenScopes != nil { inventoryBuilder = inventoryBuilder.WithFilter(github.CreateToolScopeFilter(cfg.TokenScopes)) @@ -229,6 +267,12 @@ type StdioServerConfig struct { // RepoAccessCacheTTL overrides the default TTL for repository access cache entries. RepoAccessCacheTTL *time.Duration + + // DefaultRepository scopes the server to a single owner/repo (owner/repo format). + DefaultRepository string + + // AllowDiscoveryTools keeps open-world discovery tools when DefaultRepository is set. + AllowDiscoveryTools bool } // RunStdioServer is not concurrent safe. @@ -287,6 +331,8 @@ func RunStdioServer(cfg StdioServerConfig) error { Logger: logger, RepoAccessTTL: cfg.RepoAccessCacheTTL, TokenScopes: tokenScopes, + DefaultRepository: cfg.DefaultRepository, + AllowDiscoveryTools: cfg.AllowDiscoveryTools, }) if err != nil { return fmt.Errorf("failed to create MCP server: %w", err) diff --git a/pkg/github/context_tools_test.go b/pkg/github/context_tools_test.go index ade54aba17..566d673a5e 100644 --- a/pkg/github/context_tools_test.go +++ b/pkg/github/context_tools_test.go @@ -162,6 +162,7 @@ func Test_GetMe_IFC_FeatureFlag(t *testing.T) { func(_ context.Context, flagName string) (bool, error) { return flagName == FeatureFlagIFCLabels && enabled, nil }, + RepositoryContextConfig{}, stubExporters(), ) } diff --git a/pkg/github/dependencies.go b/pkg/github/dependencies.go index 1141fbce89..b64e7a58a4 100644 --- a/pkg/github/dependencies.go +++ b/pkg/github/dependencies.go @@ -105,6 +105,16 @@ type ToolDependencies interface { // Metrics returns the metrics client Metrics(ctx context.Context) metrics.Metrics + + // GetRepositoryContext returns configured default repository context for project-focused mode. + GetRepositoryContext() RepositoryContextConfig +} + +// RepositoryContextConfig holds server-level repository scoping configuration. +type RepositoryContextConfig struct { + DefaultRepository *RepositoryRef + FocusMode bool + Token string } // BaseDeps is the standard implementation of ToolDependencies for the local server. @@ -125,6 +135,9 @@ type BaseDeps struct { // Feature flag checker for runtime checks featureChecker inventory.FeatureFlagChecker + // Repository context for project-focused mode + RepositoryContext RepositoryContextConfig + // Observability exporters (includes logger) Obsv observability.Exporters } @@ -142,6 +155,7 @@ func NewBaseDeps( flags FeatureFlags, contentWindowSize int, featureChecker inventory.FeatureFlagChecker, + repositoryContext RepositoryContextConfig, obsv observability.Exporters, ) *BaseDeps { return &BaseDeps{ @@ -153,6 +167,7 @@ func NewBaseDeps( Flags: flags, ContentWindowSize: contentWindowSize, featureChecker: featureChecker, + RepositoryContext: repositoryContext, Obsv: obsv, } } @@ -196,6 +211,11 @@ func (d BaseDeps) Metrics(ctx context.Context) metrics.Metrics { return d.Obsv.Metrics(ctx) } +// GetRepositoryContext implements ToolDependencies. +func (d BaseDeps) GetRepositoryContext() RepositoryContextConfig { + return d.RepositoryContext +} + // IsFeatureEnabled checks if a feature flag is enabled. // Returns false if the feature checker is nil, flag name is empty, or an error occurs. // This allows tools to conditionally change behavior based on feature flags. @@ -441,3 +461,8 @@ func (d *RequestDeps) IsFeatureEnabled(ctx context.Context, flagName string) boo return enabled } + +// GetRepositoryContext implements ToolDependencies. +func (d *RequestDeps) GetRepositoryContext() RepositoryContextConfig { + return RepositoryContextConfig{} +} diff --git a/pkg/github/dependencies_test.go b/pkg/github/dependencies_test.go index 1d747cae47..968a81a5b8 100644 --- a/pkg/github/dependencies_test.go +++ b/pkg/github/dependencies_test.go @@ -36,6 +36,7 @@ func TestIsFeatureEnabled_WithEnabledFlag(t *testing.T) { github.FeatureFlags{}, 0, // contentWindowSize checker, // featureChecker + github.RepositoryContextConfig{}, testExporters(), ) @@ -61,6 +62,7 @@ func TestIsFeatureEnabled_WithoutChecker(t *testing.T) { github.FeatureFlags{}, 0, // contentWindowSize nil, // featureChecker (nil) + github.RepositoryContextConfig{}, testExporters(), ) @@ -86,6 +88,7 @@ func TestIsFeatureEnabled_EmptyFlagName(t *testing.T) { github.FeatureFlags{}, 0, // contentWindowSize checker, // featureChecker + github.RepositoryContextConfig{}, testExporters(), ) @@ -111,6 +114,7 @@ func TestIsFeatureEnabled_CheckerError(t *testing.T) { github.FeatureFlags{}, 0, // contentWindowSize checker, // featureChecker + github.RepositoryContextConfig{}, testExporters(), ) diff --git a/pkg/github/feature_flags_test.go b/pkg/github/feature_flags_test.go index 3f9d211953..5b38d93afd 100644 --- a/pkg/github/feature_flags_test.go +++ b/pkg/github/feature_flags_test.go @@ -102,6 +102,7 @@ func TestHelloWorld_ConditionalBehavior_Featureflag(t *testing.T) { FeatureFlags{}, 0, featureCheckerFor(enabledFlags...), + RepositoryContextConfig{}, stubExporters(), ) diff --git a/pkg/github/repository_context.go b/pkg/github/repository_context.go new file mode 100644 index 0000000000..eda6bf9ced --- /dev/null +++ b/pkg/github/repository_context.go @@ -0,0 +1,272 @@ +package github + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "strings" + + "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/github/github-mcp-server/pkg/utils" + "github.com/google/go-github/v87/github" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// RepositoryRef identifies a GitHub repository by owner and name. +type RepositoryRef struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + FullName string `json:"full_name"` +} + +// RepositoryAccessStatus describes whether the current token can access a repository. +type RepositoryAccessStatus struct { + Accessible bool `json:"accessible"` + Private bool `json:"private,omitempty"` + Permissions *RepositoryPermissions `json:"permissions,omitempty"` + Error string `json:"error,omitempty"` + Hint string `json:"hint,omitempty"` +} + +// RepositoryPermissions summarizes repository permissions returned by the GitHub API. +type RepositoryPermissions struct { + Admin bool `json:"admin"` + Push bool `json:"push"` + Pull bool `json:"pull"` +} + +// RepositoryContextResponse is returned by the get_repository_context tool. +type RepositoryContextResponse struct { + DefaultRepository *RepositoryRef `json:"default_repository,omitempty"` + FocusMode bool `json:"focus_mode"` + TokenType string `json:"token_type,omitempty"` + RepositoryAccess *RepositoryAccessStatus `json:"repository_access,omitempty"` +} + +// repositoryDiscoveryTools are open-world tools hidden in repository focus mode. +var repositoryDiscoveryTools = map[string]struct{}{ + "search_repositories": {}, + "search_users": {}, + "search_orgs": {}, + "list_starred_repositories": {}, + "create_repository": {}, + "fork_repository": {}, +} + +// ParseRepositoryRef parses owner/repo from common Git remote and URL formats. +func ParseRepositoryRef(raw string) (RepositoryRef, error) { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return RepositoryRef{}, fmt.Errorf("repository reference is empty") + } + + if strings.Contains(trimmed, "://") || strings.HasPrefix(trimmed, "git@") { + return parseRepositoryRemote(trimmed) + } + + parts := strings.Split(strings.Trim(trimmed, "/"), "/") + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return RepositoryRef{}, fmt.Errorf("repository must be in owner/repo format, got %q", raw) + } + + owner := parts[0] + repo := strings.TrimSuffix(parts[1], ".git") + if owner == "" || repo == "" { + return RepositoryRef{}, fmt.Errorf("repository must be in owner/repo format, got %q", raw) + } + + return RepositoryRef{ + Owner: owner, + Repo: repo, + FullName: owner + "/" + repo, + }, nil +} + +func parseRepositoryRemote(raw string) (RepositoryRef, error) { + normalized := raw + if strings.HasPrefix(normalized, "git@") { + normalized = strings.Replace(normalized, ":", "/", 1) + normalized = strings.TrimPrefix(normalized, "git@") + normalized = "https://" + normalized + } + + parsed, err := url.Parse(normalized) + if err != nil { + return RepositoryRef{}, fmt.Errorf("invalid repository URL: %w", err) + } + + path := strings.Trim(parsed.Path, "/") + path = strings.TrimSuffix(path, ".git") + parts := strings.Split(path, "/") + if len(parts) < 2 { + return RepositoryRef{}, fmt.Errorf("repository URL must include owner and repo, got %q", raw) + } + + owner := parts[len(parts)-2] + repo := parts[len(parts)-1] + if owner == "" || repo == "" { + return RepositoryRef{}, fmt.Errorf("repository URL must include owner and repo, got %q", raw) + } + + return RepositoryRef{ + Owner: owner, + Repo: repo, + FullName: owner + "/" + repo, + }, nil +} + +// VerifyRepositoryAccess checks whether the token can access the given repository. +func VerifyRepositoryAccess(ctx context.Context, client *github.Client, ref RepositoryRef, tokenType utils.TokenType) RepositoryAccessStatus { + repository, resp, err := client.Repositories.Get(ctx, ref.Owner, ref.Repo) + if err != nil { + status := RepositoryAccessStatus{ + Accessible: false, + Error: err.Error(), + } + if tokenType == utils.TokenTypeFineGrainedPersonalAccessToken { + status.Hint = fmt.Sprintf( + "Fine-grained personal access tokens must explicitly include repository %s with Metadata plus the permissions you need (for example Issues and Pull requests). Collaborator access alone is not enough unless the token is authorized for this repository.", + ref.FullName, + ) + } + if resp != nil && resp.StatusCode == 404 { + status.Hint = strings.TrimSpace(status.Hint + " The repository was not found or the token cannot access it.") + } + return status + } + + status := RepositoryAccessStatus{ + Accessible: true, + Private: repository.GetPrivate(), + } + if repository.Permissions != nil { + status.Permissions = &RepositoryPermissions{ + Admin: repository.Permissions.GetAdmin(), + Push: repository.Permissions.GetPush(), + Pull: repository.Permissions.GetPull(), + } + } + return status +} + +func tokenTypeName(tokenType utils.TokenType) string { + switch tokenType { + case utils.TokenTypePersonalAccessToken: + return "classic_pat" + case utils.TokenTypeFineGrainedPersonalAccessToken: + return "fine_grained_pat" + case utils.TokenTypeOAuthAccessToken: + return "oauth" + case utils.TokenTypeUserToServerGitHubAppToken: + return "github_app_user" + case utils.TokenTypeServerToServerGitHubAppToken: + return "github_app_installation" + default: + return "unknown" + } +} + +func DetectTokenType(token string) utils.TokenType { + for prefix, tokenType := range map[string]utils.TokenType{ + "ghp_": utils.TokenTypePersonalAccessToken, + "github_pat_": utils.TokenTypeFineGrainedPersonalAccessToken, + "gho_": utils.TokenTypeOAuthAccessToken, + "ghu_": utils.TokenTypeUserToServerGitHubAppToken, + "ghs_": utils.TokenTypeServerToServerGitHubAppToken, + } { + if strings.HasPrefix(token, prefix) { + return tokenType + } + } + return utils.TokenTypeUnknown +} + +// ResolveRepositoryFocusConfig parses the configured repository and determines focus mode. +func ResolveRepositoryFocusConfig(defaultRepository string, allowDiscoveryTools bool) (RepositoryRef, bool, error) { + if strings.TrimSpace(defaultRepository) == "" { + return RepositoryRef{}, false, nil + } + + ref, err := ParseRepositoryRef(defaultRepository) + if err != nil { + return RepositoryRef{}, false, err + } + + return ref, !allowDiscoveryTools, nil +} + +// BuildRepositoryContextConfig constructs runtime repository context from server config. +func BuildRepositoryContextConfig(defaultRepository, token string, allowDiscoveryTools bool) (RepositoryContextConfig, error) { + ref, focusMode, err := ResolveRepositoryFocusConfig(defaultRepository, allowDiscoveryTools) + if err != nil { + return RepositoryContextConfig{}, err + } + + cfg := RepositoryContextConfig{ + FocusMode: focusMode, + Token: token, + } + if ref.FullName != "" { + cfg.DefaultRepository = &ref + } + return cfg, nil +} + +func CreateRepositoryFocusFilter(enabled bool) inventory.ToolFilter { + if !enabled { + return func(_ context.Context, _ *inventory.ServerTool) (bool, error) { + return true, nil + } + } + + return func(_ context.Context, tool *inventory.ServerTool) (bool, error) { + _, excluded := repositoryDiscoveryTools[tool.Tool.Name] + return !excluded, nil + } +} + +// GetRepositoryContext creates a tool that returns the configured default repository and access status. +func GetRepositoryContext(t translations.TranslationHelperFunc) inventory.ServerTool { + return NewTool( + ToolsetMetadataContext, + mcp.Tool{ + Name: "get_repository_context", + Description: t( + "TOOL_GET_REPOSITORY_CONTEXT_DESCRIPTION", + "Get the configured default repository and token access status. Call this first for project-focused work instead of searching repositories. Returns owner/repo to use with repo-scoped tools like list_issues and list_pull_requests.", + ), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_GET_REPOSITORY_CONTEXT_USER_TITLE", "Get repository context"), + ReadOnlyHint: true, + }, + InputSchema: json.RawMessage(`{"type":"object","properties":{}}`), + }, + nil, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, _ map[string]any) (*mcp.CallToolResult, any, error) { + repoCtx := deps.GetRepositoryContext() + response := RepositoryContextResponse{ + FocusMode: repoCtx.FocusMode, + } + + if repoCtx.Token != "" { + response.TokenType = tokenTypeName(DetectTokenType(repoCtx.Token)) + } + + if repoCtx.DefaultRepository != nil { + response.DefaultRepository = repoCtx.DefaultRepository + + client, err := deps.GetClient(ctx) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil + } + + access := VerifyRepositoryAccess(ctx, client, *repoCtx.DefaultRepository, DetectTokenType(repoCtx.Token)) + response.RepositoryAccess = &access + } + + return MarshalledTextResult(response), response, nil + }, + ) +} diff --git a/pkg/github/repository_context_test.go b/pkg/github/repository_context_test.go new file mode 100644 index 0000000000..fa5db55f8c --- /dev/null +++ b/pkg/github/repository_context_test.go @@ -0,0 +1,172 @@ +package github + +import ( + "context" + "net/http" + "testing" + + "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/github/github-mcp-server/pkg/utils" + "github.com/google/go-github/v87/github" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseRepositoryRef(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + want RepositoryRef + wantErr bool + }{ + { + name: "owner slash repo", + input: "github/github-mcp-server", + want: RepositoryRef{ + Owner: "github", + Repo: "github-mcp-server", + FullName: "github/github-mcp-server", + }, + }, + { + name: "https url", + input: "https://github.com/github/github-mcp-server", + want: RepositoryRef{ + Owner: "github", + Repo: "github-mcp-server", + FullName: "github/github-mcp-server", + }, + }, + { + name: "ssh remote", + input: "git@github.com:github/github-mcp-server.git", + want: RepositoryRef{ + Owner: "github", + Repo: "github-mcp-server", + FullName: "github/github-mcp-server", + }, + }, + { + name: "invalid", + input: "just-a-name", + wantErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := ParseRepositoryRef(tc.input) + if tc.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tc.want, got) + }) + } +} + +func TestResolveRepositoryFocusConfig(t *testing.T) { + t.Parallel() + + ref, focus, err := ResolveRepositoryFocusConfig("github/github-mcp-server", false) + require.NoError(t, err) + assert.Equal(t, "github/github-mcp-server", ref.FullName) + assert.True(t, focus) + + _, focus, err = ResolveRepositoryFocusConfig("github/github-mcp-server", true) + require.NoError(t, err) + assert.False(t, focus) + + _, focus, err = ResolveRepositoryFocusConfig("", false) + require.NoError(t, err) + assert.False(t, focus) +} + +func TestCreateRepositoryFocusFilter(t *testing.T) { + t.Parallel() + + filter := CreateRepositoryFocusFilter(true) + include, err := filter(context.Background(), &inventory.ServerTool{Tool: mcp.Tool{Name: "search_repositories"}}) + require.NoError(t, err) + assert.False(t, include) + + include, err = filter(context.Background(), &inventory.ServerTool{Tool: mcp.Tool{Name: "list_issues"}}) + require.NoError(t, err) + assert.True(t, include) +} + +func TestGetRepositoryContext(t *testing.T) { + t.Parallel() + + serverTool := GetRepositoryContext(translations.NullTranslationHelper) + tool := serverTool.Tool + require.Equal(t, "get_repository_context", tool.Name) + + mockRepo := &github.Repository{ + Private: github.Ptr(true), + Permissions: &github.RepositoryPermissions{ + Admin: github.Ptr(false), + Push: github.Ptr(false), + Pull: github.Ptr(true), + }, + } + + deps := NewBaseDeps( + mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposByOwnerByRepo: mockResponse(t, http.StatusOK, mockRepo), + })), + nil, + nil, + nil, + translations.NullTranslationHelper, + FeatureFlags{}, + 0, + nil, + RepositoryContextConfig{ + DefaultRepository: &RepositoryRef{ + Owner: "github", + Repo: "github-mcp-server", + FullName: "github/github-mcp-server", + }, + FocusMode: true, + Token: "github_pat_test", + }, + stubExporters(), + ) + + handler := serverTool.Handler(deps) + request := createMCPRequest(map[string]any{}) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.NotNil(t, result) + + text := getTextResult(t, result).Text + assert.Contains(t, text, `"full_name":"github/github-mcp-server"`) + assert.Contains(t, text, `"focus_mode":true`) + assert.Contains(t, text, `"token_type":"fine_grained_pat"`) + assert.Contains(t, text, `"accessible":true`) +} + +func TestVerifyRepositoryAccessFineGrainedHint(t *testing.T) { + t.Parallel() + + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposByOwnerByRepo: mockResponse(t, http.StatusNotFound, map[string]string{"message": "Not Found"}), + })) + + status := VerifyRepositoryAccess( + context.Background(), + client, + RepositoryRef{Owner: "friend", Repo: "private-repo", FullName: "friend/private-repo"}, + utils.TokenTypeFineGrainedPersonalAccessToken, + ) + + assert.False(t, status.Accessible) + assert.Contains(t, status.Hint, "Fine-grained personal access tokens") + assert.Contains(t, status.Hint, "friend/private-repo") +} diff --git a/pkg/github/server.go b/pkg/github/server.go index f56ac7d3a8..dfa0ddd80a 100644 --- a/pkg/github/server.go +++ b/pkg/github/server.go @@ -68,6 +68,15 @@ type MCPServerConfig struct { // This is used for PAT scope filtering where we can't issue scope challenges. TokenScopes []string + // DefaultRepository is the owner/repo configured for project-focused mode. + DefaultRepository string + + // FocusRepository hides open-world discovery tools when a default repository is configured. + FocusRepository bool + + // AllowDiscoveryTools keeps discovery tools available even when FocusRepository is enabled. + AllowDiscoveryTools bool + // Additional server options to apply ServerOptions []MCPServerOption } diff --git a/pkg/github/server_test.go b/pkg/github/server_test.go index 7f909f431c..b972180b5e 100644 --- a/pkg/github/server_test.go +++ b/pkg/github/server_test.go @@ -71,6 +71,7 @@ func (s stubDeps) Logger(_ context.Context) *slog.Logger { func (s stubDeps) Metrics(ctx context.Context) metrics.Metrics { return s.obsv.Metrics(ctx) } +func (s stubDeps) GetRepositoryContext() RepositoryContextConfig { return RepositoryContextConfig{} } // Helper functions to create stub client functions for error testing diff --git a/pkg/github/tools.go b/pkg/github/tools.go index d1d585b3fa..e528511eea 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -169,6 +169,7 @@ var ( func AllTools(t translations.TranslationHelperFunc) []inventory.ServerTool { return withCSVOutput([]inventory.ServerTool{ // Context tools + GetRepositoryContext(t), GetMe(t), GetTeams(t), GetTeamMembers(t), diff --git a/pkg/github/toolset_instructions.go b/pkg/github/toolset_instructions.go index ba6659612a..672b5ae725 100644 --- a/pkg/github/toolset_instructions.go +++ b/pkg/github/toolset_instructions.go @@ -1,11 +1,26 @@ package github -import "github.com/github/github-mcp-server/pkg/inventory" +import ( + "fmt" + + "github.com/github/github-mcp-server/pkg/inventory" +) // Toolset instruction functions - these generate context-aware instructions for each toolset. // They are called during inventory build to generate server instructions. -func generateContextToolsetInstructions(_ *inventory.Inventory) string { +func generateContextToolsetInstructions(inv *inventory.Inventory) string { + if ref := inv.DefaultRepository(); ref != "" { + parsed, err := ParseRepositoryRef(ref) + if err == nil { + return fmt.Sprintf( + "Project focus is enabled for %s. Call 'get_repository_context' first (not 'search_repositories' or 'get_me') to confirm repository access, then use owner=%q and repo=%q with repo-scoped tools unless the user names another repository.", + parsed.FullName, + parsed.Owner, + parsed.Repo, + ) + } + } return "Always call 'get_me' first to understand current user permissions and context." } diff --git a/pkg/inventory/builder.go b/pkg/inventory/builder.go index 9ecaca1f57..b1d59662d9 100644 --- a/pkg/inventory/builder.go +++ b/pkg/inventory/builder.go @@ -53,6 +53,8 @@ type Builder struct { featureChecker FeatureFlagChecker filters []ToolFilter // filters to apply to all tools generateInstructions bool + defaultRepository string + focusRepository bool } // NewBuilder creates a new Builder. @@ -100,6 +102,18 @@ func (b *Builder) WithServerInstructions() *Builder { return b } +// WithDefaultRepository sets the default owner/repo used in server instructions. +func (b *Builder) WithDefaultRepository(repository string) *Builder { + b.defaultRepository = strings.TrimSpace(repository) + return b +} + +// WithFocusRepository marks the inventory as project-focused for instruction generation. +func (b *Builder) WithFocusRepository(enabled bool) *Builder { + b.focusRepository = enabled + return b +} + // WithToolsets specifies which toolsets should be enabled. // Special keywords: // - "all": enables all toolsets @@ -272,6 +286,9 @@ func (b *Builder) Build() (*Inventory, error) { r.instructions = generateInstructions(r) } + r.defaultRepository = b.defaultRepository + r.focusRepository = b.focusRepository + return r, nil } diff --git a/pkg/inventory/instructions.go b/pkg/inventory/instructions.go index 02e90cd200..8457063a0f 100644 --- a/pkg/inventory/instructions.go +++ b/pkg/inventory/instructions.go @@ -1,6 +1,7 @@ package inventory import ( + "fmt" "os" "strings" ) @@ -30,6 +31,16 @@ Tool usage guidance: instructions = append(instructions, baseInstruction) + if ref := inv.DefaultRepository(); ref != "" { + instructions = append(instructions, fmt.Sprintf( + "Default repository: %s. Prefer repo-scoped list_* tools with this owner/repo instead of global search or repository discovery tools.", + ref, + )) + if inv.FocusRepository() { + instructions = append(instructions, "Repository focus mode is enabled: discovery tools such as search_repositories are hidden. Stay scoped to the default repository unless the user explicitly asks about another project.") + } + } + // Collect instructions from each enabled toolset for _, toolset := range inv.EnabledToolsets() { if toolset.InstructionsFunc != nil { diff --git a/pkg/inventory/instructions_test.go b/pkg/inventory/instructions_test.go index e8e369b3db..af7305586c 100644 --- a/pkg/inventory/instructions_test.go +++ b/pkg/inventory/instructions_test.go @@ -263,3 +263,27 @@ func TestGenerateInstructionsOnlyEnabledToolsets(t *testing.T) { t.Errorf("Did not expect instructions to contain 'PRS_INSTRUCTIONS' for disabled toolset, but it did. Result: %s", result) } } + +func TestGenerateInstructionsRepositoryFocusMode(t *testing.T) { + inv, err := NewBuilder(). + SetTools([]ServerTool{{Toolset: ToolsetMetadata{ID: "context", Description: "Context"}}}). + WithToolsets([]string{"context"}). + WithDefaultRepository("github/github-mcp-server"). + WithFocusRepository(true). + Build() + if err != nil { + t.Fatalf("Failed to build inventory: %v", err) + } + + result := generateInstructions(inv) + + if !strings.Contains(result, "Default repository: github/github-mcp-server") { + t.Errorf("Expected default repository in instructions, got: %s", result) + } + if !strings.Contains(result, "Repository focus mode is enabled") { + t.Errorf("Expected focus mode guidance in instructions, got: %s", result) + } + if !strings.Contains(result, "search_repositories are hidden") { + t.Errorf("Expected discovery tool guidance in instructions, got: %s", result) + } +} diff --git a/pkg/inventory/registry.go b/pkg/inventory/registry.go index b8a70a3420..d406d76054 100644 --- a/pkg/inventory/registry.go +++ b/pkg/inventory/registry.go @@ -60,6 +60,10 @@ type Inventory struct { unrecognizedToolsets []string // server instructions hold high-level instructions for agents to use the server effectively instructions string + // defaultRepository is the configured owner/repo for project-focused mode + defaultRepository string + // focusRepository indicates project-focused mode is enabled + focusRepository bool } // UnrecognizedToolsets returns toolset IDs that were passed to WithToolsets but don't @@ -364,3 +368,13 @@ func (r *Inventory) EnabledToolsets() []ToolsetMetadata { func (r *Inventory) Instructions() string { return r.instructions } + +// DefaultRepository returns the configured default owner/repo reference. +func (r *Inventory) DefaultRepository() string { + return r.defaultRepository +} + +// FocusRepository reports whether project-focused mode is enabled. +func (r *Inventory) FocusRepository() bool { + return r.focusRepository +}