Skip to content
Draft
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
57 changes: 57 additions & 0 deletions cmd/github-mcp-server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"time"

"github.com/github/github-mcp-server/internal/ghmcp"
"github.com/github/github-mcp-server/pkg/binding"
"github.com/github/github-mcp-server/pkg/github"
ghhttp "github.com/github/github-mcp-server/pkg/http"
"github.com/spf13/cobra"
Expand Down Expand Up @@ -78,6 +79,12 @@ var (
}

ttl := viper.GetDuration("repo-access-cache-ttl")

scope, err := resolveScope(viper.GetString("repository"), viper.GetString("pull-request"), viper.GetString("project"))
if err != nil {
return err
}

stdioServerConfig := ghmcp.StdioServerConfig{
Version: version,
Host: viper.GetString("host"),
Expand All @@ -94,6 +101,7 @@ var (
InsidersMode: viper.GetBool("insiders"),
ExcludeTools: excludeTools,
RepoAccessCacheTTL: &ttl,
Scope: scope,
}
return ghmcp.RunStdioServer(stdioServerConfig)
},
Expand Down Expand Up @@ -182,6 +190,13 @@ func init() {
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)")

// Scoped-mode flags (stdio only). Each binds the server to a single fixed
// GitHub context and exposes a bespoke tool surface for it. They are
// mutually exclusive.
stdioCmd.Flags().String("repository", "", "Bind the server to a single repository (owner/repo), exposing a repository-scoped tool surface")
stdioCmd.Flags().String("pull-request", "", "Bind the server to a single pull request (owner/repo#number), exposing a pull-request-scoped tool surface")
stdioCmd.Flags().String("project", "", "Bind the server to a single project (org|user/owner/number), exposing a project-scoped tool surface")

// HTTP-specific flags
httpCmd.Flags().Int("port", 8082, "HTTP server port")
httpCmd.Flags().String("base-url", "", "Base URL where this server is publicly accessible (for OAuth resource metadata)")
Expand All @@ -203,6 +218,9 @@ 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", stdioCmd.Flags().Lookup("repository"))
_ = viper.BindPFlag("pull-request", stdioCmd.Flags().Lookup("pull-request"))
_ = viper.BindPFlag("project", stdioCmd.Flags().Lookup("project"))
_ = 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"))
Expand Down Expand Up @@ -235,3 +253,42 @@ func wordSepNormalizeFunc(_ *pflag.FlagSet, name string) pflag.NormalizedName {
}
return pflag.NormalizedName(name)
}

// resolveScope turns the mutually-exclusive --repository / --pull-request /
// --project flags into a single binding.Context. It returns nil when none are
// set (the server runs in its normal, unscoped mode).
func resolveScope(repository, pullRequest, project string) (*binding.Context, error) {
var set []string
if repository != "" {
set = append(set, "--repository")
}
if pullRequest != "" {
set = append(set, "--pull-request")
}
if project != "" {
set = append(set, "--project")
}
if len(set) == 0 {
return nil, nil
}
if len(set) > 1 {
return nil, fmt.Errorf("flags %s are mutually exclusive; set only one scoped mode", strings.Join(set, ", "))
}

var (
ctx binding.Context
err error
)
switch {
case repository != "":
ctx, err = binding.ParseRepository(repository)
case pullRequest != "":
ctx, err = binding.ParsePullRequest(pullRequest)
case project != "":
ctx, err = binding.ParseProject(project)
}
if err != nil {
return nil, err
}
return &ctx, nil
}
41 changes: 33 additions & 8 deletions internal/ghmcp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"syscall"
"time"

"github.com/github/github-mcp-server/pkg/binding"
"github.com/github/github-mcp-server/pkg/errors"
"github.com/github/github-mcp-server/pkg/github"
"github.com/github/github-mcp-server/pkg/http/transport"
Expand Down Expand Up @@ -149,14 +150,32 @@ func NewStdioMCPServer(ctx context.Context, cfg github.MCPServerConfig) (*mcp.Se
obs,
)
// Build and register the tool/resource/prompt inventory
inventoryBuilder := github.NewInventory(cfg.Translator).
WithDeprecatedAliases(github.DeprecatedToolAliases).
WithReadOnly(cfg.ReadOnly).
WithToolsets(github.ResolvedEnabledToolsets(cfg.EnabledToolsets, cfg.EnabledTools)).
WithTools(github.CleanTools(cfg.EnabledTools)).
WithExcludeTools(cfg.ExcludeTools).
WithServerInstructions().
WithFeatureChecker(featureChecker)
var inventoryBuilder *inventory.Builder
if cfg.Scope != nil {
// Scoped mode: the manifest defines the surface, so toolset/tool/
// exclude selection flags are intentionally ignored. Read-only,
// feature-flag, and PAT-scope filtering still apply on top. All
// toolsets are enabled because the manifest — not the toolset filter —
// decides membership.
scoped, err := github.NewScopedInventory(cfg.Translator, *cfg.Scope)
if err != nil {
return nil, fmt.Errorf("failed to build scoped inventory: %w", err)
}
inventoryBuilder = scoped.
WithToolsets([]string{"all"}).
WithReadOnly(cfg.ReadOnly).
WithServerInstructions().
WithFeatureChecker(featureChecker)
} else {
inventoryBuilder = github.NewInventory(cfg.Translator).
WithDeprecatedAliases(github.DeprecatedToolAliases).
WithReadOnly(cfg.ReadOnly).
WithToolsets(github.ResolvedEnabledToolsets(cfg.EnabledToolsets, cfg.EnabledTools)).
WithTools(github.CleanTools(cfg.EnabledTools)).
WithExcludeTools(cfg.ExcludeTools).
WithServerInstructions().
WithFeatureChecker(featureChecker)
}

// Apply token scope filtering if scopes are known (for PAT filtering)
if cfg.TokenScopes != nil {
Expand Down Expand Up @@ -229,6 +248,11 @@ type StdioServerConfig struct {

// RepoAccessCacheTTL overrides the default TTL for repository access cache entries.
RepoAccessCacheTTL *time.Duration

// Scope, when non-nil, binds the server to a fixed GitHub context (a
// repository, pull request, or project), exposing the bespoke scoped tool
// surface for that context instead of the full toolset.
Scope *binding.Context
}

// RunStdioServer is not concurrent safe.
Expand Down Expand Up @@ -287,6 +311,7 @@ func RunStdioServer(cfg StdioServerConfig) error {
Logger: logger,
RepoAccessTTL: cfg.RepoAccessCacheTTL,
TokenScopes: tokenScopes,
Scope: cfg.Scope,
})
if err != nil {
return fmt.Errorf("failed to create MCP server: %w", err)
Expand Down
5 changes: 5 additions & 0 deletions pkg/binding/__toolsnaps__/project/_surface.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
[
"projects_get",
"projects_list",
"projects_write"
]
40 changes: 40 additions & 0 deletions pkg/binding/__toolsnaps__/project/projects_get.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
{
"annotations": {
"readOnlyHint": true,
"title": "Get details of GitHub Projects resources"
},
"description": "Read project #7 owned by octocat: the project itself, one of its fields, or one of its items.",
"inputSchema": {
"properties": {
"field_id": {
"description": "The field's ID. Required for 'get_project_field' method.",
"type": "number"
},
"fields": {
"description": "Specific list of field IDs to include in the response when getting a project item (e.g. [\"102589\", \"985201\", \"169875\"]). If not provided, only the title field is included. Only used for 'get_project_item' method.",
"items": {
"type": "string"
},
"type": "array"
},
"item_id": {
"description": "The item's ID. Required for 'get_project_item' method.",
"type": "number"
},
"method": {
"description": "The method to execute",
"enum": [
"get_project",
"get_project_field",
"get_project_item"
],
"type": "string"
}
},
"required": [
"method"
],
"type": "object"
},
"name": "projects_get"
}
48 changes: 48 additions & 0 deletions pkg/binding/__toolsnaps__/project/projects_list.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
{
"annotations": {
"readOnlyHint": true,
"title": "List GitHub Projects resources"
},
"description": "List the fields, items, or status updates of project #7 owned by octocat.",
"inputSchema": {
"properties": {
"after": {
"description": "Forward pagination cursor from previous pageInfo.nextCursor.",
"type": "string"
},
"before": {
"description": "Backward pagination cursor from previous pageInfo.prevCursor (rare).",
"type": "string"
},
"fields": {
"description": "Field IDs to include when listing project items (e.g. [\"102589\", \"985201\"]). CRITICAL: Always provide to get field values. Without this, only titles returned. Only used for 'list_project_items' method.",
"items": {
"type": "string"
},
"type": "array"
},
"method": {
"description": "The action to perform",
"enum": [
"list_project_fields",
"list_project_items",
"list_project_status_updates"
],
"type": "string"
},
"per_page": {
"description": "Results per page (max 50)",
"type": "number"
},
"query": {
"description": "Filter/query string. For list_projects: filter by title text and state (e.g. \"roadmap is:open\"). For list_project_items: advanced filtering using GitHub's project filtering syntax.",
"type": "string"
}
},
"required": [
"method"
],
"type": "object"
},
"name": "projects_list"
}
121 changes: 121 additions & 0 deletions pkg/binding/__toolsnaps__/project/projects_write.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
{
"annotations": {
"destructiveHint": true,
"title": "Manage GitHub Projects"
},
"description": "Manage project #7 owned by octocat: add, update, or remove items, post status updates, and create iteration fields.",
"inputSchema": {
"properties": {
"body": {
"description": "The body of the status update (markdown). Used for 'create_project_status_update' method.",
"type": "string"
},
"field_name": {
"description": "The name of the iteration field (e.g. 'Sprint'). Required for 'create_iteration_field' method.",
"type": "string"
},
"issue_number": {
"description": "The issue number (use when item_type is 'issue' for 'add_project_item' method). Provide either issue_number or pull_request_number.",
"type": "number"
},
"item_id": {
"description": "The project item ID. Required for 'update_project_item' and 'delete_project_item' methods.",
"type": "number"
},
"item_owner": {
"description": "The owner (user or organization) of the repository containing the issue or pull request. Required for 'add_project_item' method.",
"type": "string"
},
"item_repo": {
"description": "The name of the repository containing the issue or pull request. Required for 'add_project_item' method.",
"type": "string"
},
"item_type": {
"description": "The item's type, either issue or pull_request. Required for 'add_project_item' method.",
"enum": [
"issue",
"pull_request"
],
"type": "string"
},
"iteration_duration": {
"description": "Duration in days for iterations of the field (e.g. 7 for weekly, 14 for bi-weekly). Required for 'create_iteration_field' method.",
"type": "number"
},
"iterations": {
"description": "Custom iterations for 'create_iteration_field' method. Only set this when you need iterations with varying durations, breaks between them, or specific titles. Otherwise omit it: GitHub auto-creates three iterations of 'iteration_duration' days starting on 'start_date', which is the right choice for most cases.",
"items": {
"additionalProperties": false,
"properties": {
"duration": {
"description": "Duration in days",
"type": "number"
},
"start_date": {
"description": "Start date in YYYY-MM-DD format",
"type": "string"
},
"title": {
"description": "Iteration title (e.g. 'Sprint 1')",
"type": "string"
}
},
"required": [
"title",
"start_date",
"duration"
],
"type": "object"
},
"type": "array"
},
"method": {
"description": "The method to execute",
"enum": [
"add_project_item",
"update_project_item",
"delete_project_item",
"create_project_status_update",
"create_iteration_field"
],
"type": "string"
},
"pull_request_number": {
"description": "The pull request number (use when item_type is 'pull_request' for 'add_project_item' method). Provide either issue_number or pull_request_number.",
"type": "number"
},
"start_date": {
"description": "Start date in YYYY-MM-DD format. Used for 'create_project_status_update' and 'create_iteration_field' methods.",
"type": "string"
},
"status": {
"description": "The status of the project. Used for 'create_project_status_update' method.",
"enum": [
"INACTIVE",
"ON_TRACK",
"AT_RISK",
"OFF_TRACK",
"COMPLETE"
],
"type": "string"
},
"target_date": {
"description": "The target date of the status update in YYYY-MM-DD format. Used for 'create_project_status_update' method.",
"type": "string"
},
"title": {
"description": "The project title. Required for 'create_project' method.",
"type": "string"
},
"updated_field": {
"description": "Object consisting of the ID of the project field to update and the new value for the field. To clear the field, set value to null. Example: {\"id\": 123456, \"value\": \"New Value\"}. Required for 'update_project_item' method.",
"type": "object"
}
},
"required": [
"method"
],
"type": "object"
},
"name": "projects_write"
}
11 changes: 11 additions & 0 deletions pkg/binding/__toolsnaps__/pull_request/_surface.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
[
"add_comment_to_pending_review",
"add_reply_to_pull_request_comment",
"get_commit",
"get_file_contents",
"merge_pull_request",
"pull_request_read",
"pull_request_review_write",
"request_copilot_review",
"update_pull_request_branch"
]
Loading
Loading