diff --git a/cmd/github-mcp-server/main.go b/cmd/github-mcp-server/main.go index 558fdb9980..ff8743abd3 100644 --- a/cmd/github-mcp-server/main.go +++ b/cmd/github-mcp-server/main.go @@ -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" @@ -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"), @@ -94,6 +101,7 @@ var ( InsidersMode: viper.GetBool("insiders"), ExcludeTools: excludeTools, RepoAccessCacheTTL: &ttl, + Scope: scope, } return ghmcp.RunStdioServer(stdioServerConfig) }, @@ -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)") @@ -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")) @@ -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 +} diff --git a/internal/ghmcp/server.go b/internal/ghmcp/server.go index a37c4d940d..36c1fe6219 100644 --- a/internal/ghmcp/server.go +++ b/internal/ghmcp/server.go @@ -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" @@ -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 { @@ -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. @@ -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) diff --git a/pkg/binding/__toolsnaps__/project/_surface.snap b/pkg/binding/__toolsnaps__/project/_surface.snap new file mode 100644 index 0000000000..bb70aab39d --- /dev/null +++ b/pkg/binding/__toolsnaps__/project/_surface.snap @@ -0,0 +1,5 @@ +[ + "projects_get", + "projects_list", + "projects_write" +] \ No newline at end of file diff --git a/pkg/binding/__toolsnaps__/project/projects_get.snap b/pkg/binding/__toolsnaps__/project/projects_get.snap new file mode 100644 index 0000000000..0a6163f234 --- /dev/null +++ b/pkg/binding/__toolsnaps__/project/projects_get.snap @@ -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" +} \ No newline at end of file diff --git a/pkg/binding/__toolsnaps__/project/projects_list.snap b/pkg/binding/__toolsnaps__/project/projects_list.snap new file mode 100644 index 0000000000..7b886396e0 --- /dev/null +++ b/pkg/binding/__toolsnaps__/project/projects_list.snap @@ -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" +} \ No newline at end of file diff --git a/pkg/binding/__toolsnaps__/project/projects_write.snap b/pkg/binding/__toolsnaps__/project/projects_write.snap new file mode 100644 index 0000000000..5f439e48c5 --- /dev/null +++ b/pkg/binding/__toolsnaps__/project/projects_write.snap @@ -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" +} \ No newline at end of file diff --git a/pkg/binding/__toolsnaps__/pull_request/_surface.snap b/pkg/binding/__toolsnaps__/pull_request/_surface.snap new file mode 100644 index 0000000000..1de5c35ee7 --- /dev/null +++ b/pkg/binding/__toolsnaps__/pull_request/_surface.snap @@ -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" +] \ No newline at end of file diff --git a/pkg/binding/__toolsnaps__/pull_request/add_comment_to_pending_review.snap b/pkg/binding/__toolsnaps__/pull_request/add_comment_to_pending_review.snap new file mode 100644 index 0000000000..67b327065f --- /dev/null +++ b/pkg/binding/__toolsnaps__/pull_request/add_comment_to_pending_review.snap @@ -0,0 +1,57 @@ +{ + "annotations": { + "title": "Add review comment to the requester's latest pending pull request review" + }, + "description": "Add a comment to your pending review on pull request octocat/hello-world#42.", + "inputSchema": { + "properties": { + "body": { + "description": "The text of the review comment", + "type": "string" + }, + "line": { + "description": "The line of the blob in the pull request diff that the comment applies to. For multi-line comments, the last line of the range", + "type": "number" + }, + "path": { + "description": "The relative path to the file that necessitates a comment", + "type": "string" + }, + "side": { + "description": "The side of the diff to comment on. LEFT indicates the previous state, RIGHT indicates the new state", + "enum": [ + "LEFT", + "RIGHT" + ], + "type": "string" + }, + "startLine": { + "description": "For multi-line comments, the first line of the range that the comment applies to", + "type": "number" + }, + "startSide": { + "description": "For multi-line comments, the starting side of the diff that the comment applies to. LEFT indicates the previous state, RIGHT indicates the new state", + "enum": [ + "LEFT", + "RIGHT" + ], + "type": "string" + }, + "subjectType": { + "description": "The level at which the comment is targeted", + "enum": [ + "FILE", + "LINE" + ], + "type": "string" + } + }, + "required": [ + "path", + "body", + "subjectType" + ], + "type": "object" + }, + "name": "add_comment_to_pending_review" +} \ No newline at end of file diff --git a/pkg/binding/__toolsnaps__/pull_request/add_reply_to_pull_request_comment.snap b/pkg/binding/__toolsnaps__/pull_request/add_reply_to_pull_request_comment.snap new file mode 100644 index 0000000000..15d5a6107b --- /dev/null +++ b/pkg/binding/__toolsnaps__/pull_request/add_reply_to_pull_request_comment.snap @@ -0,0 +1,24 @@ +{ + "annotations": { + "title": "Add reply to pull request comment" + }, + "description": "Reply to an existing review comment on pull request octocat/hello-world#42.", + "inputSchema": { + "properties": { + "body": { + "description": "The text of the reply", + "type": "string" + }, + "commentId": { + "description": "The ID of the comment to reply to", + "type": "number" + } + }, + "required": [ + "commentId", + "body" + ], + "type": "object" + }, + "name": "add_reply_to_pull_request_comment" +} \ No newline at end of file diff --git a/pkg/binding/__toolsnaps__/pull_request/get_commit.snap b/pkg/binding/__toolsnaps__/pull_request/get_commit.snap new file mode 100644 index 0000000000..005923773c --- /dev/null +++ b/pkg/binding/__toolsnaps__/pull_request/get_commit.snap @@ -0,0 +1,41 @@ +{ + "annotations": { + "readOnlyHint": true, + "title": "Get commit details" + }, + "description": "Get the details and diff of a single commit in octocat/hello-world, the repository of pull request octocat/hello-world#42.", + "inputSchema": { + "properties": { + "detail": { + "default": "stats", + "description": "Level of detail to include for changed files. \"none\" omits stats and files entirely. \"stats\" (default) includes per-file metadata: filename, status, and lines-of-code counts (additions, deletions, changes), with no patch content. \"full_patch\" additionally includes the unified diff content for each file and can be very large.", + "enum": [ + "none", + "stats", + "full_patch" + ], + "type": "string" + }, + "page": { + "description": "Page number for pagination (min 1)", + "minimum": 1, + "type": "number" + }, + "perPage": { + "description": "Results per page for pagination (min 1, max 100)", + "maximum": 100, + "minimum": 1, + "type": "number" + }, + "sha": { + "description": "Commit SHA, branch name, or tag name", + "type": "string" + } + }, + "required": [ + "sha" + ], + "type": "object" + }, + "name": "get_commit" +} \ No newline at end of file diff --git a/pkg/binding/__toolsnaps__/pull_request/get_file_contents.snap b/pkg/binding/__toolsnaps__/pull_request/get_file_contents.snap new file mode 100644 index 0000000000..631a5690f5 --- /dev/null +++ b/pkg/binding/__toolsnaps__/pull_request/get_file_contents.snap @@ -0,0 +1,26 @@ +{ + "annotations": { + "readOnlyHint": true, + "title": "Get file or directory contents" + }, + "description": "Read a file's contents or list a directory in octocat/hello-world, the repository of pull request octocat/hello-world#42.", + "inputSchema": { + "properties": { + "path": { + "default": "/", + "description": "Path to file/directory", + "type": "string" + }, + "ref": { + "description": "Accepts optional git refs such as `refs/tags/{tag}`, `refs/heads/{branch}` or `refs/pull/{pr_number}/head`", + "type": "string" + }, + "sha": { + "description": "Accepts optional commit SHA. If specified, it will be used instead of ref", + "type": "string" + } + }, + "type": "object" + }, + "name": "get_file_contents" +} \ No newline at end of file diff --git a/pkg/binding/__toolsnaps__/pull_request/merge_pull_request.snap b/pkg/binding/__toolsnaps__/pull_request/merge_pull_request.snap new file mode 100644 index 0000000000..9ac52bc5d4 --- /dev/null +++ b/pkg/binding/__toolsnaps__/pull_request/merge_pull_request.snap @@ -0,0 +1,41 @@ +{ + "annotations": { + "title": "Merge pull request" + }, + "description": "Merge pull request octocat/hello-world#42.", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAACeElEQVRIibWVTUhUYRSGn/e74+iiQih1F9Vcmj9sptylUVBYkO4jcNeuJBdFKxe1CYQokGrRKjCEdtmqwEVmtqomQWeiUdc2EBUtUufe0yLHn1KLGXtX5zvn4zz3vd8f/Gfp90Qs0drmpA6MT1EveDo1NfV92wB+KnMdo39Nfs4L7eSHD5Nz1QJcJYglWtsw+iUehAuRRjO1g+0KHLerbb4OIHnHAC1FdW129s3XmUJuwnBDoOPbA7BwHsD7QWq1HKYN5msBRCpB1AueLoSROSkciSUyj5ClhE6BLtYC8CpBqVRabNrdMmIiJdQjuUbQ1WI+d78WwIbykxnzU9np7ejlNq2YxQ4ebNtTKyCyWcEgYl55EDj/a7ihFEtkLkr0As2YxjwL+9aem00dCEYNzvnJzLDvH27aaM5y80HEnKGHKGwPnEbT6fSOvzpAmrDQnkncpC7siiUzz2QqIPu25iOuGBorTufO/AJmH0v2ajHwuoHhrQHATOH9rQPJ7IjDLgs6kZ0F6it1AzArVcZLdUE+WnYgmv/uYFmz+dxH4NJGNT+RfYLCE7F4tn0pGkxHy94AmBm8/GfAVvIs7AukUTkbj5YdYIbZ9WJh8m1lzrrbNB4/tD+QuyPsdCibF26gmM/dY/NdRDqd3rEYeN04mswYL+ZXm68DxOPxnWXXMClsp+GGhCWBTtClYj53t1qXK78oVH2XYB/mHZ0pvHsN4Cczzw3rBaoGrJ6D5ZUvN1i+kjI0LWiptjmscbC88hZZCAf2trZeq1v0UsJ6wF7UAlhxUMxPvkW6AboQLbvPcjaO+BIx11cL4I9H308eOiLRQUhpOx79/66fNKzrOCYNDm0AAAAASUVORK5CYII=", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABjElEQVRIibWVPS/DURTGnysSC0HiZdWVrZ28JDaLT8BHaBsMdjqZJDXiAzC2LF5mX6GtATGiIsGARH+Gnj9X8a/kf3uWe3Py3Oc559xz75E6bK7VAWQkzUi6lXTonHsOpgYUgAZfdgmkQpFnjHwb6AemgDpQCiWwYlEPeL4i8JCEt8vb39g67vkmPH8yA3qt5nVgCzi1jLJBBEwkBZSAdxPKAj86LYQQQCU4cYvAKzDUSYF3YC+uRIAD8sA58ACU//VuTODE1n1g+A9c3jBH1tJ1a5TeCPNrdACSCpKeJG1IepN0LKkm6dGDrkqqOOdm7dyUpDNJi865PUnqjsvEObcJHEhaljQnaV5STwvszttXbR2J441KtB4LauLKVpZpYBDYte8mHUogZTWPrAGstTtQBl6AayDX7qHZD7AALMVGDvQBV5ZyETi2qHLtMvmXWRQAk57vBKgl4fV/0+jmq56vImk0icCnAWm7pB3riGngnlADx0TW+T4yL4CxJJy/Df20mkP/TqGHfifsA7INs3X5i3+yAAAAAElFTkSuQmCC", + "theme": "dark" + } + ], + "inputSchema": { + "properties": { + "commit_message": { + "description": "Extra detail for merge commit", + "type": "string" + }, + "commit_title": { + "description": "Title for merge commit", + "type": "string" + }, + "merge_method": { + "description": "Merge method", + "enum": [ + "merge", + "squash", + "rebase" + ], + "type": "string" + } + }, + "type": "object" + }, + "name": "merge_pull_request" +} \ No newline at end of file diff --git a/pkg/binding/__toolsnaps__/pull_request/pull_request_read.snap b/pkg/binding/__toolsnaps__/pull_request/pull_request_read.snap new file mode 100644 index 0000000000..ac48ec32b2 --- /dev/null +++ b/pkg/binding/__toolsnaps__/pull_request/pull_request_read.snap @@ -0,0 +1,46 @@ +{ + "annotations": { + "readOnlyHint": true, + "title": "Get details for a single pull request" + }, + "description": "Read pull request octocat/hello-world#42: its details, diff, changed files, commits, reviews, review comments, or status.", + "inputSchema": { + "properties": { + "after": { + "description": "Cursor for pagination, used only by the get_review_comments method. Pass the endCursor from the previous page's PageInfo to fetch the next page.", + "type": "string" + }, + "method": { + "description": "Action to specify what pull request data needs to be retrieved from GitHub. \nPossible options: \n 1. get - Get details of a specific pull request.\n 2. get_diff - Get the diff of a pull request.\n 3. get_status - Get combined commit status of a head commit in a pull request.\n 4. get_files - Get the list of files changed in a pull request. Use with pagination parameters to control the number of results returned.\n 5. get_commits - Get the list of commits on a pull request. Use with pagination parameters to control the number of results returned.\n 6. get_review_comments - Get review threads on a pull request. Each thread contains logically grouped review comments made on the same code location during pull request reviews. Returns threads with metadata (isResolved, isOutdated, isCollapsed) and their associated comments. Use cursor-based pagination (perPage, after) to control results.\n 7. get_reviews - Get the reviews on a pull request. When asked for review comments, use get_review_comments method. Use with pagination parameters to control the number of results returned.\n 8. get_comments - Get comments on a pull request. Use this if user doesn't specifically want review comments. Use with pagination parameters to control the number of results returned.\n 9. get_check_runs - Get check runs for the head commit of a pull request. Check runs are the individual CI/CD jobs and checks that run on the PR.\n", + "enum": [ + "get", + "get_diff", + "get_status", + "get_files", + "get_commits", + "get_review_comments", + "get_reviews", + "get_comments", + "get_check_runs" + ], + "type": "string" + }, + "page": { + "description": "Page number for pagination (min 1)", + "minimum": 1, + "type": "number" + }, + "perPage": { + "description": "Results per page for pagination (min 1, max 100)", + "maximum": 100, + "minimum": 1, + "type": "number" + } + }, + "required": [ + "method" + ], + "type": "object" + }, + "name": "pull_request_read" +} \ No newline at end of file diff --git a/pkg/binding/__toolsnaps__/pull_request/pull_request_review_write.snap b/pkg/binding/__toolsnaps__/pull_request/pull_request_review_write.snap new file mode 100644 index 0000000000..f07f9c6f1d --- /dev/null +++ b/pkg/binding/__toolsnaps__/pull_request/pull_request_review_write.snap @@ -0,0 +1,41 @@ +{ + "annotations": { + "title": "Write operations (create, submit, delete) on pull request reviews" + }, + "description": "Create, submit, or discard a pending review on pull request octocat/hello-world#42.", + "inputSchema": { + "properties": { + "body": { + "description": "Review comment text", + "type": "string" + }, + "commitID": { + "description": "SHA of commit to review", + "type": "string" + }, + "event": { + "description": "Review action to perform.", + "enum": [ + "APPROVE", + "REQUEST_CHANGES", + "COMMENT" + ], + "type": "string" + }, + "method": { + "description": "The write operation to perform on pull request review.", + "enum": [ + "create", + "submit_pending", + "delete_pending" + ], + "type": "string" + } + }, + "required": [ + "method" + ], + "type": "object" + }, + "name": "pull_request_review_write" +} \ No newline at end of file diff --git a/pkg/binding/__toolsnaps__/pull_request/request_copilot_review.snap b/pkg/binding/__toolsnaps__/pull_request/request_copilot_review.snap new file mode 100644 index 0000000000..102125470f --- /dev/null +++ b/pkg/binding/__toolsnaps__/pull_request/request_copilot_review.snap @@ -0,0 +1,23 @@ +{ + "annotations": { + "title": "Request Copilot review" + }, + "description": "Request a GitHub Copilot review on pull request octocat/hello-world#42.", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAC20lEQVRIidWUS4wMURSGv3O7kWmPEMRrSMzcbl1dpqtmGuOxsCKECCKxEBusSJhIWEhsWLFAbC1sWFiISBARCyQ2kzSZGaMxHokgXvGIiMH0PRZjpJqqHpb+TeX+59z//H/q5sD/DqlX9H1/zFeX2qzIKoFWYDKgwBtUymL0UkNaT3V3d3/+5wG2EGxB9TDIxGFMvhVhb9/drpN/NaDJC7MGdwJk6TDCv0Gvq0lve9R762GUNdFDLleaZNBrICGq+4yhvf9TJtP/KZNB2PrLlbBliBfRhajuAwnFVa/n8/nkxFkv3GO9oJrzgwVxdesV71ov6I2r5fxggfWCatYL9yYmUJgLPH7Q29WZ4OED6Me4wuAdeQK6MMqna9t0GuibBHFAmgZ9JMG9BhkXZWoSCDSATIq7aguBD0wBplq/tZBgYDIwKnZAs99mFRYD9vd/YK0dpcqhobM6d9haWyOULRTbAauwuNlvsxHTYP3iBnVyXGAa8BIYC3oVeAKioCtAPEE7FCOgR0ErIJdBBZgNskzh40+NF6K6s+9e91lp9osrxMnFoTSmSmPVsF+E5cB0YEDgtoMjjypd5wCy+WC9GnajhEAa4bkqV9LOHKwa9/yneYeyUqwX3AdyQ5EeVrrqro/hYL0g+ggemKh4HGbPmVu0+fB8U76lpR6XgJwZpoGUpNYiusZg1tXjkmCAav0OMTXfJC4eVYPqwbot6l4BCPqyLhd7lwMAWC/cYb3gi/UCzRaKOxsbFzVEM1iv2Ebt5v2Dm14qZbJecZf1Ah3UCrcTbbB+awHnjgHLgHeinHYqZ8aPSXWWy+XvcQZLpdKI9/0D7UbZiLIJmABckVSqo+/OrUrNgF+D8q1LEdcBrAJGAJ8ROlGeicorABWdAswE5gOjge8CF8Ad66v03IjqJb75WS0tE0YOmNWqLBGReaAzgIkMLrt3oM9UpSzCzW9pd+FpT8/7JK3/Gz8Ao5X6wtwP7N4AAAAASUVORK5CYII=", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAACCElEQVRIid2UPWsUYRSFn3dxWWJUkESiBgslFokfhehGiGClBBQx4h9IGlEh2ijYxh+gxEL/hIWwhYpF8KNZsFRJYdJEiUbjCkqisj4W+y6Mk5nd1U4PDMOce+45L3fmDvzXUDeo59WK+kb9rn5TF9R76jm1+2/NJ9QPtseSOv4nxrvVmQ6M05hRB9qZ98ZR1NRralntitdEwmw8wQ9HbS329rQKuKLW1XJO/aX6IqdWjr1Xk/y6lG4vMBdCqOacoZZ3uBBCVZ0HDrcK2AYs5ZkAuwBb1N8Dm5JEISXoAnqzOtU9QB+wVR3KCdgClDIr6kCc4c/0O1BLNnahiYpaSmmGY62e/JpCLJ4FpmmMaBHYCDwC5mmMZBQYBC7HnhvAK+B+fN4JHAM+R4+3wGQI4S7qaExtol+9o86pq+oX9Yk6ljjtGfVprK2qr9Xb6vaET109jjqb3Jac2XaM1PLNpok1Aep+G/+dfa24nADTX1EWTgOngLE2XCYKQL0DTfKex2WhXgCutxG9i/fFNlwWpgBQL6orcWyTaldToRbUA2pow61XL0WPFfXCb1HqkPowCj6q0+qIWsw7nlpUj6i31OXY+0AdbGpCRtNRGgt1AigCX4EqsJAYTR+wAzgEdAM/gApwM4TwOOm3JiARtBk4CYwAB4F+oIfGZi/HwOfAM6ASQviU5/Vv4xcBzmW2eT1nrQAAAABJRU5ErkJggg==", + "theme": "dark" + } + ], + "inputSchema": { + "properties": {}, + "type": "object" + }, + "name": "request_copilot_review" +} \ No newline at end of file diff --git a/pkg/binding/__toolsnaps__/pull_request/update_pull_request_branch.snap b/pkg/binding/__toolsnaps__/pull_request/update_pull_request_branch.snap new file mode 100644 index 0000000000..4d86a259c4 --- /dev/null +++ b/pkg/binding/__toolsnaps__/pull_request/update_pull_request_branch.snap @@ -0,0 +1,16 @@ +{ + "annotations": { + "title": "Update pull request branch" + }, + "description": "Update the branch of pull request octocat/hello-world#42 with the latest changes from its base branch.", + "inputSchema": { + "properties": { + "expectedHeadSha": { + "description": "The expected SHA of the pull request's HEAD ref", + "type": "string" + } + }, + "type": "object" + }, + "name": "update_pull_request_branch" +} \ No newline at end of file diff --git a/pkg/binding/__toolsnaps__/repo/_surface.snap b/pkg/binding/__toolsnaps__/repo/_surface.snap new file mode 100644 index 0000000000..fa2857d854 --- /dev/null +++ b/pkg/binding/__toolsnaps__/repo/_surface.snap @@ -0,0 +1,18 @@ +[ + "add_issue_comment", + "create_branch", + "create_or_update_file", + "create_pull_request", + "delete_file", + "get_commit", + "get_file_contents", + "issue_read", + "list_branches", + "list_commits", + "list_issues", + "list_pull_requests", + "pull_request_read", + "push_files", + "search_issues", + "search_pull_requests" +] \ No newline at end of file diff --git a/pkg/binding/__toolsnaps__/repo/add_issue_comment.snap b/pkg/binding/__toolsnaps__/repo/add_issue_comment.snap new file mode 100644 index 0000000000..811d8f0259 --- /dev/null +++ b/pkg/binding/__toolsnaps__/repo/add_issue_comment.snap @@ -0,0 +1,24 @@ +{ + "annotations": { + "title": "Add comment to issue or pull request" + }, + "description": "Add a comment to an issue or pull request in octocat/hello-world.", + "inputSchema": { + "properties": { + "body": { + "description": "Comment content", + "type": "string" + }, + "issue_number": { + "description": "Issue number to comment on", + "type": "number" + } + }, + "required": [ + "issue_number", + "body" + ], + "type": "object" + }, + "name": "add_issue_comment" +} \ No newline at end of file diff --git a/pkg/binding/__toolsnaps__/repo/create_branch.snap b/pkg/binding/__toolsnaps__/repo/create_branch.snap new file mode 100644 index 0000000000..3b9a3dfa4a --- /dev/null +++ b/pkg/binding/__toolsnaps__/repo/create_branch.snap @@ -0,0 +1,23 @@ +{ + "annotations": { + "title": "Create branch" + }, + "description": "Create a new branch in octocat/hello-world.", + "inputSchema": { + "properties": { + "branch": { + "description": "Name for new branch", + "type": "string" + }, + "from_branch": { + "description": "Source branch (defaults to repo default)", + "type": "string" + } + }, + "required": [ + "branch" + ], + "type": "object" + }, + "name": "create_branch" +} \ No newline at end of file diff --git a/pkg/binding/__toolsnaps__/repo/create_or_update_file.snap b/pkg/binding/__toolsnaps__/repo/create_or_update_file.snap new file mode 100644 index 0000000000..f378b7baf1 --- /dev/null +++ b/pkg/binding/__toolsnaps__/repo/create_or_update_file.snap @@ -0,0 +1,38 @@ +{ + "annotations": { + "title": "Create or update file" + }, + "description": "Create a new file or update an existing file in octocat/hello-world.", + "inputSchema": { + "properties": { + "branch": { + "description": "Branch to create/update the file in", + "type": "string" + }, + "content": { + "description": "Content of the file", + "type": "string" + }, + "message": { + "description": "Commit message", + "type": "string" + }, + "path": { + "description": "Path where to create/update the file", + "type": "string" + }, + "sha": { + "description": "The blob SHA of the file being replaced. Required if the file already exists.", + "type": "string" + } + }, + "required": [ + "path", + "content", + "message", + "branch" + ], + "type": "object" + }, + "name": "create_or_update_file" +} \ No newline at end of file diff --git a/pkg/binding/__toolsnaps__/repo/create_pull_request.snap b/pkg/binding/__toolsnaps__/repo/create_pull_request.snap new file mode 100644 index 0000000000..8641553928 --- /dev/null +++ b/pkg/binding/__toolsnaps__/repo/create_pull_request.snap @@ -0,0 +1,41 @@ +{ + "annotations": { + "title": "Open new pull request" + }, + "description": "Open a new pull request in octocat/hello-world.", + "inputSchema": { + "properties": { + "base": { + "description": "Branch to merge into", + "type": "string" + }, + "body": { + "description": "PR description", + "type": "string" + }, + "draft": { + "description": "Create as draft PR", + "type": "boolean" + }, + "head": { + "description": "Branch containing changes", + "type": "string" + }, + "maintainer_can_modify": { + "description": "Allow maintainer edits", + "type": "boolean" + }, + "title": { + "description": "PR title", + "type": "string" + } + }, + "required": [ + "title", + "head", + "base" + ], + "type": "object" + }, + "name": "create_pull_request" +} \ No newline at end of file diff --git a/pkg/binding/__toolsnaps__/repo/delete_file.snap b/pkg/binding/__toolsnaps__/repo/delete_file.snap new file mode 100644 index 0000000000..86f5d7c398 --- /dev/null +++ b/pkg/binding/__toolsnaps__/repo/delete_file.snap @@ -0,0 +1,30 @@ +{ + "annotations": { + "destructiveHint": true, + "title": "Delete file" + }, + "description": "Delete a file from octocat/hello-world.", + "inputSchema": { + "properties": { + "branch": { + "description": "Branch to delete the file from", + "type": "string" + }, + "message": { + "description": "Commit message", + "type": "string" + }, + "path": { + "description": "Path to the file to delete", + "type": "string" + } + }, + "required": [ + "path", + "message", + "branch" + ], + "type": "object" + }, + "name": "delete_file" +} \ No newline at end of file diff --git a/pkg/binding/__toolsnaps__/repo/get_commit.snap b/pkg/binding/__toolsnaps__/repo/get_commit.snap new file mode 100644 index 0000000000..80c1af4164 --- /dev/null +++ b/pkg/binding/__toolsnaps__/repo/get_commit.snap @@ -0,0 +1,41 @@ +{ + "annotations": { + "readOnlyHint": true, + "title": "Get commit details" + }, + "description": "Get the details and diff of a single commit in octocat/hello-world.", + "inputSchema": { + "properties": { + "detail": { + "default": "stats", + "description": "Level of detail to include for changed files. \"none\" omits stats and files entirely. \"stats\" (default) includes per-file metadata: filename, status, and lines-of-code counts (additions, deletions, changes), with no patch content. \"full_patch\" additionally includes the unified diff content for each file and can be very large.", + "enum": [ + "none", + "stats", + "full_patch" + ], + "type": "string" + }, + "page": { + "description": "Page number for pagination (min 1)", + "minimum": 1, + "type": "number" + }, + "perPage": { + "description": "Results per page for pagination (min 1, max 100)", + "maximum": 100, + "minimum": 1, + "type": "number" + }, + "sha": { + "description": "Commit SHA, branch name, or tag name", + "type": "string" + } + }, + "required": [ + "sha" + ], + "type": "object" + }, + "name": "get_commit" +} \ No newline at end of file diff --git a/pkg/binding/__toolsnaps__/repo/get_file_contents.snap b/pkg/binding/__toolsnaps__/repo/get_file_contents.snap new file mode 100644 index 0000000000..3b5de4609c --- /dev/null +++ b/pkg/binding/__toolsnaps__/repo/get_file_contents.snap @@ -0,0 +1,26 @@ +{ + "annotations": { + "readOnlyHint": true, + "title": "Get file or directory contents" + }, + "description": "Read a file's contents or list a directory in octocat/hello-world.", + "inputSchema": { + "properties": { + "path": { + "default": "/", + "description": "Path to file/directory", + "type": "string" + }, + "ref": { + "description": "Accepts optional git refs such as `refs/tags/{tag}`, `refs/heads/{branch}` or `refs/pull/{pr_number}/head`", + "type": "string" + }, + "sha": { + "description": "Accepts optional commit SHA. If specified, it will be used instead of ref", + "type": "string" + } + }, + "type": "object" + }, + "name": "get_file_contents" +} \ No newline at end of file diff --git a/pkg/binding/__toolsnaps__/repo/issue_read.snap b/pkg/binding/__toolsnaps__/repo/issue_read.snap new file mode 100644 index 0000000000..705fb63523 --- /dev/null +++ b/pkg/binding/__toolsnaps__/repo/issue_read.snap @@ -0,0 +1,42 @@ +{ + "annotations": { + "readOnlyHint": true, + "title": "Get issue details" + }, + "description": "Read an issue in octocat/hello-world: its details, comments, sub-issues, or labels.", + "inputSchema": { + "properties": { + "issue_number": { + "description": "The number of the issue", + "type": "number" + }, + "method": { + "description": "The read operation to perform on a single issue.\nOptions are:\n1. get - Get details of a specific issue.\n2. get_comments - Get issue comments.\n3. get_sub_issues - Get sub-issues of the issue.\n4. get_labels - Get labels assigned to the issue.\n", + "enum": [ + "get", + "get_comments", + "get_sub_issues", + "get_labels" + ], + "type": "string" + }, + "page": { + "description": "Page number for pagination (min 1)", + "minimum": 1, + "type": "number" + }, + "perPage": { + "description": "Results per page for pagination (min 1, max 100)", + "maximum": 100, + "minimum": 1, + "type": "number" + } + }, + "required": [ + "method", + "issue_number" + ], + "type": "object" + }, + "name": "issue_read" +} \ No newline at end of file diff --git a/pkg/binding/__toolsnaps__/repo/list_branches.snap b/pkg/binding/__toolsnaps__/repo/list_branches.snap new file mode 100644 index 0000000000..1f70082f97 --- /dev/null +++ b/pkg/binding/__toolsnaps__/repo/list_branches.snap @@ -0,0 +1,24 @@ +{ + "annotations": { + "readOnlyHint": true, + "title": "List branches" + }, + "description": "List the branches in octocat/hello-world.", + "inputSchema": { + "properties": { + "page": { + "description": "Page number for pagination (min 1)", + "minimum": 1, + "type": "number" + }, + "perPage": { + "description": "Results per page for pagination (min 1, max 100)", + "maximum": 100, + "minimum": 1, + "type": "number" + } + }, + "type": "object" + }, + "name": "list_branches" +} \ No newline at end of file diff --git a/pkg/binding/__toolsnaps__/repo/list_commits.snap b/pkg/binding/__toolsnaps__/repo/list_commits.snap new file mode 100644 index 0000000000..85d9750d20 --- /dev/null +++ b/pkg/binding/__toolsnaps__/repo/list_commits.snap @@ -0,0 +1,44 @@ +{ + "annotations": { + "readOnlyHint": true, + "title": "List commits" + }, + "description": "List commits on a branch of octocat/hello-world.", + "inputSchema": { + "properties": { + "author": { + "description": "Author username or email address to filter commits by", + "type": "string" + }, + "page": { + "description": "Page number for pagination (min 1)", + "minimum": 1, + "type": "number" + }, + "path": { + "description": "Only commits containing this file path will be returned", + "type": "string" + }, + "perPage": { + "description": "Results per page for pagination (min 1, max 100)", + "maximum": 100, + "minimum": 1, + "type": "number" + }, + "sha": { + "description": "Commit SHA, branch or tag name to list commits of. If not provided, uses the default branch of the repository. If a commit SHA is provided, will list commits up to that SHA.", + "type": "string" + }, + "since": { + "description": "Only commits after this date will be returned (ISO 8601 format: YYYY-MM-DDTHH:MM:SSZ or YYYY-MM-DD)", + "type": "string" + }, + "until": { + "description": "Only commits before this date will be returned (ISO 8601 format: YYYY-MM-DDTHH:MM:SSZ or YYYY-MM-DD)", + "type": "string" + } + }, + "type": "object" + }, + "name": "list_commits" +} \ No newline at end of file diff --git a/pkg/binding/__toolsnaps__/repo/list_issues.snap b/pkg/binding/__toolsnaps__/repo/list_issues.snap new file mode 100644 index 0000000000..907335c765 --- /dev/null +++ b/pkg/binding/__toolsnaps__/repo/list_issues.snap @@ -0,0 +1,59 @@ +{ + "annotations": { + "readOnlyHint": true, + "title": "List issues" + }, + "description": "List issues in octocat/hello-world.", + "inputSchema": { + "properties": { + "after": { + "description": "Cursor for pagination. Use the cursor from the previous response.", + "type": "string" + }, + "direction": { + "description": "Order direction. If provided, the 'orderBy' also needs to be provided.", + "enum": [ + "ASC", + "DESC" + ], + "type": "string" + }, + "labels": { + "description": "Filter by labels", + "items": { + "type": "string" + }, + "type": "array" + }, + "orderBy": { + "description": "Order issues by field. If provided, the 'direction' also needs to be provided.", + "enum": [ + "CREATED_AT", + "UPDATED_AT", + "COMMENTS" + ], + "type": "string" + }, + "perPage": { + "description": "Results per page for pagination (min 1, max 100)", + "maximum": 100, + "minimum": 1, + "type": "number" + }, + "since": { + "description": "Filter by date (ISO 8601 timestamp)", + "type": "string" + }, + "state": { + "description": "Filter by state, by default both open and closed issues are returned when not provided", + "enum": [ + "OPEN", + "CLOSED" + ], + "type": "string" + } + }, + "type": "object" + }, + "name": "list_issues" +} \ No newline at end of file diff --git a/pkg/binding/__toolsnaps__/repo/list_pull_requests.snap b/pkg/binding/__toolsnaps__/repo/list_pull_requests.snap new file mode 100644 index 0000000000..c3bc8ab078 --- /dev/null +++ b/pkg/binding/__toolsnaps__/repo/list_pull_requests.snap @@ -0,0 +1,59 @@ +{ + "annotations": { + "readOnlyHint": true, + "title": "List pull requests" + }, + "description": "List pull requests in octocat/hello-world.", + "inputSchema": { + "properties": { + "base": { + "description": "Filter by base branch", + "type": "string" + }, + "direction": { + "description": "Sort direction", + "enum": [ + "asc", + "desc" + ], + "type": "string" + }, + "head": { + "description": "Filter by head user/org and branch", + "type": "string" + }, + "page": { + "description": "Page number for pagination (min 1)", + "minimum": 1, + "type": "number" + }, + "perPage": { + "description": "Results per page for pagination (min 1, max 100)", + "maximum": 100, + "minimum": 1, + "type": "number" + }, + "sort": { + "description": "Sort by", + "enum": [ + "created", + "updated", + "popularity", + "long-running" + ], + "type": "string" + }, + "state": { + "description": "Filter by state", + "enum": [ + "open", + "closed", + "all" + ], + "type": "string" + } + }, + "type": "object" + }, + "name": "list_pull_requests" +} \ No newline at end of file diff --git a/pkg/binding/__toolsnaps__/repo/pull_request_read.snap b/pkg/binding/__toolsnaps__/repo/pull_request_read.snap new file mode 100644 index 0000000000..9ca01940dd --- /dev/null +++ b/pkg/binding/__toolsnaps__/repo/pull_request_read.snap @@ -0,0 +1,51 @@ +{ + "annotations": { + "readOnlyHint": true, + "title": "Get details for a single pull request" + }, + "description": "Read a pull request in octocat/hello-world: its details, diff, changed files, commits, reviews, comments, or status.", + "inputSchema": { + "properties": { + "after": { + "description": "Cursor for pagination, used only by the get_review_comments method. Pass the endCursor from the previous page's PageInfo to fetch the next page.", + "type": "string" + }, + "method": { + "description": "Action to specify what pull request data needs to be retrieved from GitHub. \nPossible options: \n 1. get - Get details of a specific pull request.\n 2. get_diff - Get the diff of a pull request.\n 3. get_status - Get combined commit status of a head commit in a pull request.\n 4. get_files - Get the list of files changed in a pull request. Use with pagination parameters to control the number of results returned.\n 5. get_commits - Get the list of commits on a pull request. Use with pagination parameters to control the number of results returned.\n 6. get_review_comments - Get review threads on a pull request. Each thread contains logically grouped review comments made on the same code location during pull request reviews. Returns threads with metadata (isResolved, isOutdated, isCollapsed) and their associated comments. Use cursor-based pagination (perPage, after) to control results.\n 7. get_reviews - Get the reviews on a pull request. When asked for review comments, use get_review_comments method. Use with pagination parameters to control the number of results returned.\n 8. get_comments - Get comments on a pull request. Use this if user doesn't specifically want review comments. Use with pagination parameters to control the number of results returned.\n 9. get_check_runs - Get check runs for the head commit of a pull request. Check runs are the individual CI/CD jobs and checks that run on the PR.\n", + "enum": [ + "get", + "get_diff", + "get_status", + "get_files", + "get_commits", + "get_review_comments", + "get_reviews", + "get_comments", + "get_check_runs" + ], + "type": "string" + }, + "page": { + "description": "Page number for pagination (min 1)", + "minimum": 1, + "type": "number" + }, + "perPage": { + "description": "Results per page for pagination (min 1, max 100)", + "maximum": 100, + "minimum": 1, + "type": "number" + }, + "pullNumber": { + "description": "Pull request number", + "type": "number" + } + }, + "required": [ + "method", + "pullNumber" + ], + "type": "object" + }, + "name": "pull_request_read" +} \ No newline at end of file diff --git a/pkg/binding/__toolsnaps__/repo/push_files.snap b/pkg/binding/__toolsnaps__/repo/push_files.snap new file mode 100644 index 0000000000..e259eed720 --- /dev/null +++ b/pkg/binding/__toolsnaps__/repo/push_files.snap @@ -0,0 +1,47 @@ +{ + "annotations": { + "title": "Push files to repository" + }, + "description": "Commit and push multiple file changes to a branch in octocat/hello-world in a single operation.", + "inputSchema": { + "properties": { + "branch": { + "description": "Branch to push to", + "type": "string" + }, + "files": { + "description": "Array of file objects to push, each object with path (string) and content (string)", + "items": { + "additionalProperties": false, + "properties": { + "content": { + "description": "file content", + "type": "string" + }, + "path": { + "description": "path to the file", + "type": "string" + } + }, + "required": [ + "path", + "content" + ], + "type": "object" + }, + "type": "array" + }, + "message": { + "description": "Commit message", + "type": "string" + } + }, + "required": [ + "branch", + "files", + "message" + ], + "type": "object" + }, + "name": "push_files" +} \ No newline at end of file diff --git a/pkg/binding/__toolsnaps__/repo/search_issues.snap b/pkg/binding/__toolsnaps__/repo/search_issues.snap new file mode 100644 index 0000000000..2ba614d0b1 --- /dev/null +++ b/pkg/binding/__toolsnaps__/repo/search_issues.snap @@ -0,0 +1,56 @@ +{ + "annotations": { + "readOnlyHint": true, + "title": "Search issues" + }, + "description": "Search issues within octocat/hello-world.", + "inputSchema": { + "properties": { + "order": { + "description": "Sort order", + "enum": [ + "asc", + "desc" + ], + "type": "string" + }, + "page": { + "description": "Page number for pagination (min 1)", + "minimum": 1, + "type": "number" + }, + "perPage": { + "description": "Results per page for pagination (min 1, max 100)", + "maximum": 100, + "minimum": 1, + "type": "number" + }, + "query": { + "description": "Search query using GitHub issues search syntax", + "type": "string" + }, + "sort": { + "description": "Sort field by number of matches of categories, defaults to best match", + "enum": [ + "comments", + "reactions", + "reactions-+1", + "reactions--1", + "reactions-smile", + "reactions-thinking_face", + "reactions-heart", + "reactions-tada", + "interactions", + "created", + "updated" + ], + "type": "string" + } + }, + "required": [ + "query" + ], + "type": "object" + }, + "name": "search_issues" +} \ No newline at end of file diff --git a/pkg/binding/__toolsnaps__/repo/search_pull_requests.snap b/pkg/binding/__toolsnaps__/repo/search_pull_requests.snap new file mode 100644 index 0000000000..b2064a2cb3 --- /dev/null +++ b/pkg/binding/__toolsnaps__/repo/search_pull_requests.snap @@ -0,0 +1,56 @@ +{ + "annotations": { + "readOnlyHint": true, + "title": "Search pull requests" + }, + "description": "Search pull requests within octocat/hello-world.", + "inputSchema": { + "properties": { + "order": { + "description": "Sort order", + "enum": [ + "asc", + "desc" + ], + "type": "string" + }, + "page": { + "description": "Page number for pagination (min 1)", + "minimum": 1, + "type": "number" + }, + "perPage": { + "description": "Results per page for pagination (min 1, max 100)", + "maximum": 100, + "minimum": 1, + "type": "number" + }, + "query": { + "description": "Search query using GitHub pull request search syntax", + "type": "string" + }, + "sort": { + "description": "Sort field by number of matches of categories, defaults to best match", + "enum": [ + "comments", + "reactions", + "reactions-+1", + "reactions--1", + "reactions-smile", + "reactions-thinking_face", + "reactions-heart", + "reactions-tada", + "interactions", + "created", + "updated" + ], + "type": "string" + } + }, + "required": [ + "query" + ], + "type": "object" + }, + "name": "search_pull_requests" +} \ No newline at end of file diff --git a/pkg/binding/apply.go b/pkg/binding/apply.go new file mode 100644 index 0000000000..296af64757 --- /dev/null +++ b/pkg/binding/apply.go @@ -0,0 +1,49 @@ +package binding + +import ( + "fmt" + + "github.com/github/github-mcp-server/pkg/inventory" +) + +// ApplyTools transforms the full tool universe into the scoped surface for the +// bound context. Tools admitted by the context's manifest are bound (schema +// pruned, description rewritten, handler wrapped); every other tool is dropped. +// +// The result is a []inventory.ServerTool that can be handed to +// inventory.Builder.SetTools exactly like the unscoped universe, so all +// downstream filtering (read-only, feature flags, toolsets, PAT scopes) runs +// unchanged on top of the scoped set. +func ApplyTools(universe []inventory.ServerTool, ctx Context) ([]inventory.ServerTool, error) { + m, ok := ManifestFor(ctx.Kind) + if !ok { + return nil, fmt.Errorf("no manifest for scope kind %q", ctx.Kind) + } + + out := make([]inventory.ServerTool, 0, len(m.Admit)) + for _, st := range universe { + tb, admitted := m.Admit[st.Tool.Name] + if !admitted { + continue + } + bound, err := bindTool(st, tb, ctx) + if err != nil { + return nil, err + } + out = append(out, bound) + } + return out, nil +} + +// ApplyResources scopes resource templates. Resource templates carry their own +// owner/repo context and cannot yet be bound safely, so v1 drops them entirely +// in every scoped mode. +func ApplyResources(_ []inventory.ServerResourceTemplate, _ Context) []inventory.ServerResourceTemplate { + return nil +} + +// ApplyPrompts scopes prompts. Prompts may take owner/repo arguments and are +// dropped in v1 scoped modes for the same reason as resource templates. +func ApplyPrompts(_ []inventory.ServerPrompt, _ Context) []inventory.ServerPrompt { + return nil +} diff --git a/pkg/binding/bind.go b/pkg/binding/bind.go new file mode 100644 index 0000000000..22d42ba0dd --- /dev/null +++ b/pkg/binding/bind.go @@ -0,0 +1,301 @@ +package binding + +import ( + "context" + "encoding/json" + "fmt" + "slices" + "strings" + "text/template" + + "github.com/github/github-mcp-server/pkg/inventory" + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// bindTool produces a scoped copy of a tool: its advertised schema has the +// bound, rejected, and disallowed-method fields removed, its description is +// rewritten for the bound context, and its handler injects the fixed values +// and enforces the boundary at call time. The original ServerTool (a +// package-level singleton) is never mutated. +func bindTool(st inventory.ServerTool, tb ToolBinding, ctx Context) (inventory.ServerTool, error) { + schema, ok := st.Tool.InputSchema.(*jsonschema.Schema) + if !ok { + return inventory.ServerTool{}, fmt.Errorf("tool %q: unexpected input schema type %T", st.Tool.Name, st.Tool.InputSchema) + } + + newSchema, err := transformSchema(schema, tb) + if err != nil { + return inventory.ServerTool{}, fmt.Errorf("tool %q: %w", st.Tool.Name, err) + } + + bound := st // shallow struct copy; Tool is a value, so edits below are local + bound.Tool.InputSchema = newSchema + if tb.Description != "" { + desc, err := renderDescription(tb.Description, ctx) + if err != nil { + return inventory.ServerTool{}, fmt.Errorf("tool %q: %w", st.Tool.Name, err) + } + bound.Tool.Description = desc + } + if tb.Title != "" { + bound.Tool.Title = tb.Title + } + bound.HandlerFunc = wrapHandler(st.HandlerFunc, tb, ctx) + return bound, nil +} + +// renderDescription expands a manifest description against the bound context so +// it names the concrete resource (e.g. "octocat/hello-world"). A description is +// a Go text/template with the Context in scope, exposing the RepoRef/PullRef/ +// ProjectRef helpers and the Context fields. Plain descriptions (no template +// actions) are returned unchanged. A malformed template is a manifest bug and +// fails loudly at bind time. +func renderDescription(tmpl string, ctx Context) (string, error) { + if !strings.Contains(tmpl, "{{") { + return tmpl, nil + } + t, err := template.New("description").Option("missingkey=error").Parse(tmpl) + if err != nil { + return "", fmt.Errorf("invalid description template %q: %w", tmpl, err) + } + var sb strings.Builder + if err := t.Execute(&sb, ctx); err != nil { + return "", fmt.Errorf("rendering description %q: %w", tmpl, err) + } + return sb.String(), nil +} + +// transformSchema returns a deep copy of the tool's input schema with bound and +// rejected parameters removed and the method enum narrowed to the allowed set. +func transformSchema(orig *jsonschema.Schema, tb ToolBinding) (*jsonschema.Schema, error) { + s := orig.CloneSchemas() + + remove := func(name string) { + delete(s.Properties, name) + s.Required = removeString(s.Required, name) + } + + for param := range tb.Bind { + if _, ok := s.Properties[param]; !ok { + return nil, fmt.Errorf("bound parameter %q is not present in the tool schema", param) + } + remove(param) + } + for _, param := range tb.ParamReject { + if _, ok := s.Properties[param]; !ok { + return nil, fmt.Errorf("rejected parameter %q is not present in the tool schema", param) + } + remove(param) + } + + if len(tb.MethodAllow) > 0 || len(tb.MethodDeny) > 0 { + method, ok := s.Properties["method"] + if !ok { + return nil, fmt.Errorf("a method allow/deny list is set but the tool schema has no %q parameter", "method") + } + narrowed, err := narrowEnum(method.Enum, tb.MethodAllow, tb.MethodDeny) + if err != nil { + return nil, err + } + method.Enum = narrowed + } + + if len(s.Required) == 0 { + s.Required = nil + } + return s, nil +} + +// narrowEnum returns the advertised method enum after applying a manifest's +// allow and deny lists. It keeps the original values (preserving their order) +// that survive both filters: a value is kept if it is not denied and, when an +// allow list is given, is in it. Every allow and deny value must exist in the +// original enum, so a stale manifest entry fails loudly rather than silently +// advertising — or pretending to remove — a method the tool does not implement. +func narrowEnum(enum []any, allow, deny []string) ([]any, error) { + enumSet := make(map[string]bool, len(enum)) + for _, e := range enum { + if s, ok := e.(string); ok { + enumSet[s] = true + } + } + + denySet := make(map[string]bool, len(deny)) + for _, d := range deny { + if !enumSet[d] { + return nil, fmt.Errorf("denied method %q is not one of the tool's methods", d) + } + denySet[d] = true + } + + var allowSet map[string]bool + if len(allow) > 0 { + allowSet = make(map[string]bool, len(allow)) + for _, a := range allow { + if !enumSet[a] { + return nil, fmt.Errorf("allowed method %q is not one of the tool's methods", a) + } + allowSet[a] = true + } + } + + var out []any + for _, e := range enum { + s, ok := e.(string) + if !ok { + continue + } + if denySet[s] { + continue + } + if allowSet != nil && !allowSet[s] { + continue + } + out = append(out, e) + } + if len(out) == 0 { + return nil, fmt.Errorf("method enum is empty after narrowing") + } + return out, nil +} + +// wrapHandler returns a HandlerFunc that validates and injects the bound +// context before delegating to the original handler. It rejects any +// caller-supplied value for a fixed parameter, enforces the method allow/deny +// lists (the SDK does not validate the narrowed enum), guards search queries, +// and injects the fixed values into the raw arguments. +func wrapHandler(orig inventory.HandlerFunc, tb ToolBinding, ctx Context) inventory.HandlerFunc { + return func(deps any) mcp.ToolHandler { + inner := orig(deps) + return func(c context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := map[string]any{} + if raw := req.Params.Arguments; len(raw) > 0 { + if err := json.Unmarshal(raw, &args); err != nil { + return toolError("invalid arguments: %s", err), nil + } + if args == nil { // arguments were JSON null + args = map[string]any{} + } + } + + for param := range tb.Bind { + if _, supplied := args[param]; supplied { + return toolError("parameter %q is fixed by this server and must not be supplied", param), nil + } + } + for _, param := range tb.ParamReject { + if _, supplied := args[param]; supplied { + return toolError("parameter %q is not available on this server", param), nil + } + } + + if len(tb.MethodAllow) > 0 || len(tb.MethodDeny) > 0 { + method, ok := args["method"].(string) + if !ok || method == "" { + return toolError("parameter %q is required on this server", "method"), nil + } + if !methodPermitted(method, tb) { + return toolError("method %q is not available on this server", method), nil + } + } + + if tb.QueryGuard { + if q, ok := args["query"].(string); ok && queryCanEscapeScope(q) { + return toolError("search query may not contain repo:, org:, or user: qualifiers or boolean grouping on this server"), nil + } + } + + for param, key := range tb.Bind { + v, ok := ctx.value(key) + if !ok { + return toolError("server misconfigured: no value bound for parameter %q", param), nil + } + args[param] = v + } + + raw, err := json.Marshal(args) + if err != nil { + return toolError("failed to encode arguments: %s", err), nil + } + + // Copy the request and params so the caller's request is untouched. + newParams := *req.Params + newParams.Arguments = raw + newReq := *req + newReq.Params = &newParams + return inner(c, &newReq) + } + } +} + +func methodPermitted(method string, tb ToolBinding) bool { + if slices.Contains(tb.MethodDeny, method) { + return false + } + if len(tb.MethodAllow) > 0 && !slices.Contains(tb.MethodAllow, method) { + return false + } + return true +} + +// crossContextQualifiers are GitHub search qualifiers that can redirect a query +// at a different owner/repo/user than the bound context. "-repo:" and friends +// contain these substrings and are caught too. +var crossContextQualifiers = []string{"repo:", "org:", "user:"} + +// booleanOperators are the GitHub search boolean keywords. A scoped search +// prepends a "repo:" qualifier to the caller's query; a disjunction or +// negation could move part of the query outside that qualifier and search other +// contexts, so any of these (and grouping parentheses) is rejected outright +// rather than rewritten. +var booleanOperators = map[string]bool{"OR": true, "AND": true, "NOT": true} + +// queryCanEscapeScope reports whether a search query could reach beyond the +// bound context, either via an explicit cross-context qualifier or via boolean +// grouping that would not inherit the injected repo: qualifier. +func queryCanEscapeScope(query string) bool { + return hasCrossContextQualifier(query) || hasBooleanGrouping(query) +} + +func hasCrossContextQualifier(query string) bool { + lower := strings.ToLower(query) + for _, qualifier := range crossContextQualifiers { + if strings.Contains(lower, qualifier) { + return true + } + } + return false +} + +func hasBooleanGrouping(query string) bool { + if strings.ContainsAny(query, "()") { + return true + } + for field := range strings.FieldsSeq(query) { + if booleanOperators[field] { + return true + } + } + return false +} + +func removeString(ss []string, target string) []string { + if len(ss) == 0 { + return ss + } + out := make([]string, 0, len(ss)) + for _, s := range ss { + if s != target { + out = append(out, s) + } + } + return out +} + +func toolError(format string, a ...any) *mcp.CallToolResult { + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: fmt.Sprintf(format, a...)}}, + IsError: true, + } +} diff --git a/pkg/binding/bind_test.go b/pkg/binding/bind_test.go new file mode 100644 index 0000000000..6bad466399 --- /dev/null +++ b/pkg/binding/bind_test.go @@ -0,0 +1,244 @@ +package binding + +import ( + "context" + "encoding/json" + "testing" + + "github.com/github/github-mcp-server/pkg/inventory" + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// syntheticTool builds a small ServerTool whose handler records the arguments +// it ultimately receives, so tests can assert exactly what the binding wrapper +// injected, rejected, or passed through. +func syntheticTool(captured *map[string]any) inventory.ServerTool { + schema := &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": {Type: "string"}, + "repo": {Type: "string"}, + "query": {Type: "string"}, + "body": {Type: "string"}, + "method": {Type: "string", Enum: []any{"get", "create", "delete"}}, + }, + Required: []string{"owner", "repo", "method"}, + } + return inventory.ServerTool{ + Tool: mcp.Tool{Name: "synthetic", Description: "original", InputSchema: schema}, + HandlerFunc: func(_ any) mcp.ToolHandler { + return func(_ context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + m := map[string]any{} + if len(req.Params.Arguments) > 0 { + if err := json.Unmarshal(req.Params.Arguments, &m); err != nil { + return nil, err + } + } + *captured = m + return &mcp.CallToolResult{}, nil + } + }, + } +} + +func repoCtx(t *testing.T) Context { + t.Helper() + c, err := NewRepoContext("octocat", "hello-world") + require.NoError(t, err) + return c +} + +func callBound(t *testing.T, bound inventory.ServerTool, args map[string]any) *mcp.CallToolResult { + t.Helper() + raw, err := json.Marshal(args) + require.NoError(t, err) + res, err := bound.HandlerFunc(nil)(context.Background(), &mcp.CallToolRequest{ + Params: &mcp.CallToolParamsRaw{Arguments: raw}, + }) + require.NoError(t, err) + return res +} + +func TestBindToolPrunesSchemaAndKeepsSingletonIntact(t *testing.T) { + var captured map[string]any + st := syntheticTool(&captured) + orig := st.Tool.InputSchema.(*jsonschema.Schema) + + tb := ToolBinding{ + Bind: bindRepo, + MethodAllow: []string{"get", "create"}, + Description: "bespoke", + } + bound, err := bindTool(st, tb, repoCtx(t)) + require.NoError(t, err) + + got := bound.Tool.InputSchema.(*jsonschema.Schema) + assert.NotContains(t, got.Properties, "owner", "bound owner must be removed from advertised schema") + assert.NotContains(t, got.Properties, "repo", "bound repo must be removed from advertised schema") + assert.NotContains(t, got.Required, "owner") + assert.NotContains(t, got.Required, "repo") + assert.Equal(t, []any{"get", "create"}, got.Properties["method"].Enum) + assert.Equal(t, "bespoke", bound.Tool.Description) + + // The package-level singleton must be untouched: properties and the nested + // method enum on the original schema are unchanged. + assert.Contains(t, orig.Properties, "owner") + assert.Contains(t, orig.Properties, "repo") + assert.Equal(t, []any{"get", "create", "delete"}, orig.Properties["method"].Enum) + assert.Equal(t, "original", st.Tool.Description) +} + +func TestWrapHandlerInjectsBoundValues(t *testing.T) { + var captured map[string]any + st := syntheticTool(&captured) + bound, err := bindTool(st, ToolBinding{Bind: bindRepo, Description: "d"}, repoCtx(t)) + require.NoError(t, err) + + res := callBound(t, bound, map[string]any{"method": "get"}) + require.False(t, res.IsError) + assert.Equal(t, "octocat", captured["owner"]) + assert.Equal(t, "hello-world", captured["repo"]) + assert.Equal(t, "get", captured["method"]) +} + +func TestWrapHandlerRejectsSuppliedBoundParam(t *testing.T) { + var captured map[string]any + st := syntheticTool(&captured) + bound, err := bindTool(st, ToolBinding{Bind: bindRepo, Description: "d"}, repoCtx(t)) + require.NoError(t, err) + + res := callBound(t, bound, map[string]any{"owner": "attacker", "method": "get"}) + require.True(t, res.IsError, "supplying a fixed parameter must be rejected") + assert.Nil(t, captured, "handler must not run when a fixed parameter is supplied") +} + +func TestWrapHandlerRejectsRejectedParam(t *testing.T) { + var captured map[string]any + st := syntheticTool(&captured) + bound, err := bindTool(st, ToolBinding{Bind: bindRepo, ParamReject: []string{"body"}, Description: "d"}, repoCtx(t)) + require.NoError(t, err) + + res := callBound(t, bound, map[string]any{"body": "x", "method": "get"}) + require.True(t, res.IsError, "supplying a rejected parameter must be rejected") + assert.Nil(t, captured) +} + +func TestWrapHandlerEnforcesMethodAllow(t *testing.T) { + var captured map[string]any + st := syntheticTool(&captured) + bound, err := bindTool(st, ToolBinding{Bind: bindRepo, MethodAllow: []string{"get"}, Description: "d"}, repoCtx(t)) + require.NoError(t, err) + + res := callBound(t, bound, map[string]any{"method": "create"}) + require.True(t, res.IsError, "a method outside the allow list must be rejected at runtime") + assert.Nil(t, captured) + + captured = nil + res = callBound(t, bound, map[string]any{"method": "get"}) + require.False(t, res.IsError) + assert.Equal(t, "get", captured["method"]) +} + +func TestWrapHandlerEnforcesMethodDeny(t *testing.T) { + var captured map[string]any + st := syntheticTool(&captured) + bound, err := bindTool(st, ToolBinding{Bind: bindRepo, MethodDeny: []string{"delete"}, Description: "d"}, repoCtx(t)) + require.NoError(t, err) + + res := callBound(t, bound, map[string]any{"method": "delete"}) + require.True(t, res.IsError, "a denied method must be rejected at runtime") + assert.Nil(t, captured) +} + +func TestWrapHandlerQueryGuard(t *testing.T) { + var captured map[string]any + st := syntheticTool(&captured) + bound, err := bindTool(st, ToolBinding{Bind: bindRepo, QueryGuard: true, Description: "d"}, repoCtx(t)) + require.NoError(t, err) + + for _, q := range []string{"repo:other/x foo", "is:open ORG:evil", "-user:someone", "bug OR is:issue label:x", "(is:open)", "a AND b"} { + captured = nil + res := callBound(t, bound, map[string]any{"method": "get", "query": q}) + require.Truef(t, res.IsError, "query %q that can escape the bound scope must be rejected", q) + assert.Nil(t, captured) + } + + captured = nil + res := callBound(t, bound, map[string]any{"method": "get", "query": "is:open label:bug"}) + require.False(t, res.IsError, "a query without cross-context qualifiers must pass") + assert.Equal(t, "octocat", captured["owner"]) +} + +func TestWrapHandlerRequiresMethodWhenRestricted(t *testing.T) { + var captured map[string]any + st := syntheticTool(&captured) + bound, err := bindTool(st, ToolBinding{Bind: bindRepo, MethodAllow: []string{"get"}, Description: "d"}, repoCtx(t)) + require.NoError(t, err) + + // An omitted method must not fall through to the handler when the method is + // restricted, even for a deny-only configuration where the empty string is + // not explicitly denied. + res := callBound(t, bound, map[string]any{}) + require.True(t, res.IsError, "an omitted method must be rejected when the method is restricted") + assert.Nil(t, captured) +} + +func TestBindToolErrorsOnUnknownRejectedParam(t *testing.T) { + var captured map[string]any + st := syntheticTool(&captured) + _, err := bindTool(st, ToolBinding{Bind: bindRepo, ParamReject: []string{"nonexistent"}, Description: "d"}, repoCtx(t)) + require.Error(t, err, "rejecting a parameter that is not in the schema must fail loudly") +} + +func TestNarrowEnum(t *testing.T) { + enum := []any{"get", "create", "delete"} + + got, err := narrowEnum(enum, []string{"create", "get"}, nil) + require.NoError(t, err) + assert.Equal(t, []any{"get", "create"}, got, "order follows the original enum, not the allow list") + + got, err = narrowEnum(enum, nil, []string{"delete"}) + require.NoError(t, err) + assert.Equal(t, []any{"get", "create"}, got) + + got, err = narrowEnum(enum, []string{"get", "create"}, []string{"create"}) + require.NoError(t, err) + assert.Equal(t, []any{"get"}, got) + + _, err = narrowEnum(enum, []string{"bogus"}, nil) + require.Error(t, err, "an allow value absent from the enum must fail loudly") + + _, err = narrowEnum(enum, nil, []string{"bogus"}) + require.Error(t, err, "a deny value absent from the enum must fail loudly") + + _, err = narrowEnum(enum, []string{"get"}, []string{"get"}) + require.Error(t, err, "narrowing to an empty enum must fail") +} + +func TestBindToolRendersResourceSpecificDescription(t *testing.T) { + var captured map[string]any + st := syntheticTool(&captured) + tb := ToolBinding{Bind: bindRepo, Description: "Read a file in {{.RepoRef}}."} + bound, err := bindTool(st, tb, repoCtx(t)) + require.NoError(t, err) + assert.Equal(t, "Read a file in octocat/hello-world.", bound.Tool.Description, + "the bound description must name the concrete resource, not a generic placeholder") +} + +func TestBindToolRejectsMalformedDescriptionTemplate(t *testing.T) { + var captured map[string]any + st := syntheticTool(&captured) + tb := ToolBinding{Bind: bindRepo, Description: "Broken {{.RepoRef"} + _, err := bindTool(st, tb, repoCtx(t)) + require.Error(t, err, "a malformed description template must fail loudly at bind time") +} + +func TestBindToolErrorsOnUnknownBoundParam(t *testing.T) { + var captured map[string]any + st := syntheticTool(&captured) + _, err := bindTool(st, ToolBinding{Bind: map[string]ctxKey{"nonexistent": keyOwner}, Description: "d"}, repoCtx(t)) + require.Error(t, err, "binding a parameter that is not in the schema must fail") +} diff --git a/pkg/binding/context.go b/pkg/binding/context.go new file mode 100644 index 0000000000..1f0c7eba57 --- /dev/null +++ b/pkg/binding/context.go @@ -0,0 +1,216 @@ +// Package binding turns the full GitHub MCP tool universe into a bespoke, +// context-scoped tool surface. +// +// A scoped server is bound once, by the operator, to a fixed GitHub context +// (a repository, a pull request, or a project). Inside that mode the binding +// layer presents what looks like a purpose-built server for that single +// context: the context-identifying parameters (owner, repo, pull number, …) +// are removed from each tool's advertised input schema and injected +// server-side, unsupported method values are pruned from the schema, and +// tools that cannot be structurally confined to the bound context are omitted +// entirely. +// +// The design is interface-first. The per-mode Manifest (see manifest.go) is +// the product: it declares exactly which tools appear, how they are described, +// which parameters they expose, and which values are fixed. Everything else in +// this package is the shared plumbing that adapts the existing tool handlers to +// serve that declared interface without duplicating their logic. +package binding + +import ( + "fmt" + "strconv" + "strings" +) + +// Kind identifies a scoped server mode. +type Kind string + +const ( + // KindRepo binds the server to a single repository ({owner, repo}). + KindRepo Kind = "repo" + // KindPullRequest binds the server to a single pull request + // ({owner, repo, pullNumber}). + KindPullRequest Kind = "pull_request" + // KindProject binds the server to a single ProjectsV2 project + // ({ownerType, owner, projectNumber}). + KindProject Kind = "project" +) + +// Context is the fixed GitHub context a scoped server is bound to. It is +// constructed once from operator configuration (a CLI flag or an HTTP route) +// and is immutable for the lifetime of the server. +type Context struct { + Kind Kind + + // Owner is the repository or project owner login. Set for every kind. + Owner string + // Repo is the repository name. Set for repo and pull_request kinds. + Repo string + // PullNumber is the pull request number. Set for pull_request kind. + PullNumber int + + // OwnerType is "user" or "org" (the value the projects tools expect). + // Set for project kind. + OwnerType string + // ProjectNumber is the ProjectsV2 project number. Set for project kind. + ProjectNumber int +} + +// RepoRef returns the "owner/repo" reference for the bound repository. It is +// used to name the concrete resource in tool descriptions so a scoped surface +// reads as purpose-built rather than generic ("... in octocat/hello-world" +// instead of "... in this repository"). Set for repo and pull_request kinds. +func (c Context) RepoRef() string { + return c.Owner + "/" + c.Repo +} + +// PullRef returns the "owner/repo#number" reference for the bound pull request. +func (c Context) PullRef() string { + return fmt.Sprintf("%s/%s#%d", c.Owner, c.Repo, c.PullNumber) +} + +// ProjectRef returns a human reference to the bound project, e.g. +// "project #7 owned by octocat". +func (c Context) ProjectRef() string { + return fmt.Sprintf("project #%d owned by %s", c.ProjectNumber, c.Owner) +} + +// ctxKey names a single bound value within a Context. Manifest entries map a +// tool's schema parameter to one of these keys; the binding wrapper then +// injects the correctly typed value (string vs number) at call time. +type ctxKey string + +const ( + keyOwner ctxKey = "owner" + keyRepo ctxKey = "repo" + keyPullNumber ctxKey = "pullNumber" + keyOwnerType ctxKey = "ownerType" + keyProjectNumber ctxKey = "projectNumber" +) + +// value returns the JSON-typed value for a bound key (string for logins, +// int for numbers) and whether it is set on this Context. A missing value is +// a server misconfiguration for any manifest that references the key, and the +// wrapper rejects the call rather than guessing. +func (c Context) value(k ctxKey) (any, bool) { + switch k { + case keyOwner: + return c.Owner, c.Owner != "" + case keyRepo: + return c.Repo, c.Repo != "" + case keyPullNumber: + return c.PullNumber, c.PullNumber > 0 + case keyOwnerType: + return c.OwnerType, c.OwnerType != "" + case keyProjectNumber: + return c.ProjectNumber, c.ProjectNumber > 0 + default: + return nil, false + } +} + +// NewRepoContext builds a validated repo-mode context. +func NewRepoContext(owner, repo string) (Context, error) { + if owner == "" || repo == "" { + return Context{}, fmt.Errorf("repository context requires owner and repo, got %q/%q", owner, repo) + } + return Context{Kind: KindRepo, Owner: owner, Repo: repo}, nil +} + +// NewPullRequestContext builds a validated pull-request-mode context. +func NewPullRequestContext(owner, repo string, pullNumber int) (Context, error) { + if owner == "" || repo == "" { + return Context{}, fmt.Errorf("pull request context requires owner and repo, got %q/%q", owner, repo) + } + if pullNumber <= 0 { + return Context{}, fmt.Errorf("pull request context requires a positive pull number, got %d", pullNumber) + } + return Context{Kind: KindPullRequest, Owner: owner, Repo: repo, PullNumber: pullNumber}, nil +} + +// NewProjectContext builds a validated project-mode context. ownerType must be +// "user" or "org" (the values the projects tools accept). +func NewProjectContext(ownerType, owner string, projectNumber int) (Context, error) { + if owner == "" { + return Context{}, fmt.Errorf("project context requires an owner") + } + if projectNumber <= 0 { + return Context{}, fmt.Errorf("project context requires a positive project number, got %d", projectNumber) + } + switch ownerType { + case "user", "org": + default: + return Context{}, fmt.Errorf("project owner type must be %q or %q, got %q", "user", "org", ownerType) + } + return Context{Kind: KindProject, Owner: owner, OwnerType: ownerType, ProjectNumber: projectNumber}, nil +} + +// ParseRepository parses the repo-mode flag value "owner/repo". +func ParseRepository(s string) (Context, error) { + owner, repo, ok := splitOwnerRepo(s) + if !ok { + return Context{}, fmt.Errorf("invalid --repository %q, want owner/repo", s) + } + return NewRepoContext(owner, repo) +} + +// ParsePullRequest parses the pull-request-mode flag value "owner/repo#N". +func ParsePullRequest(s string) (Context, error) { + repoPart, numPart, ok := strings.Cut(s, "#") + if !ok { + return Context{}, fmt.Errorf("invalid --pull-request %q, want owner/repo#number", s) + } + owner, repo, ok := splitOwnerRepo(repoPart) + if !ok { + return Context{}, fmt.Errorf("invalid --pull-request %q, want owner/repo#number", s) + } + n, err := strconv.Atoi(strings.TrimSpace(numPart)) + if err != nil { + return Context{}, fmt.Errorf("invalid --pull-request %q: %q is not a number", s, numPart) + } + return NewPullRequestContext(owner, repo, n) +} + +// ParseProject parses the project-mode flag value. Accepted forms: +// +// org/owner/N user/owner/N (canonical owner-type prefixes) +// orgs/owner/N users/owner/N (plural convenience forms) +// +// The owner-type prefix is required so the bound surface is unambiguous. +func ParseProject(s string) (Context, error) { + parts := strings.Split(s, "/") + if len(parts) != 3 { + return Context{}, fmt.Errorf("invalid --project %q, want org|user/owner/number", s) + } + ownerType, err := normalizeOwnerType(parts[0]) + if err != nil { + return Context{}, fmt.Errorf("invalid --project %q: %w", s, err) + } + owner := strings.TrimSpace(parts[1]) + n, err := strconv.Atoi(strings.TrimSpace(parts[2])) + if err != nil { + return Context{}, fmt.Errorf("invalid --project %q: %q is not a number", s, parts[2]) + } + return NewProjectContext(ownerType, owner, n) +} + +func normalizeOwnerType(s string) (string, error) { + switch strings.ToLower(strings.TrimSpace(s)) { + case "org", "orgs", "organization": + return "org", nil + case "user", "users": + return "user", nil + default: + return "", fmt.Errorf("owner type %q must be org or user", s) + } +} + +func splitOwnerRepo(s string) (owner, repo string, ok bool) { + owner, repo, _ = strings.Cut(strings.TrimSpace(s), "/") + owner, repo = strings.TrimSpace(owner), strings.TrimSpace(repo) + if owner == "" || repo == "" || strings.Contains(repo, "/") { + return "", "", false + } + return owner, repo, true +} diff --git a/pkg/binding/manifest.go b/pkg/binding/manifest.go new file mode 100644 index 0000000000..a58082aa93 --- /dev/null +++ b/pkg/binding/manifest.go @@ -0,0 +1,305 @@ +package binding + +import "fmt" + +// ToolBinding declares how a single tool appears and behaves inside a scoped +// mode. It is the per-tool slice of the interface spec: which fixed values are +// injected, which parameters and method values are removed from the advertised +// schema, and how the tool is described to the end user. +// +// Membership is explicit: a tool with no ToolBinding in a Manifest is omitted +// from that mode entirely (fail-closed). Adding a new tool to the server does +// nothing to a scoped surface until it is deliberately admitted here. +type ToolBinding struct { + // Bind maps a tool input-schema parameter to the Context value that is + // injected for it. Bound parameters are removed from the advertised schema + // and may not be supplied by the caller. + Bind map[string]ctxKey + + // MethodAllow restricts a multi-method tool's "method" parameter to this + // set. The advertised enum is narrowed to it and the runtime rejects any + // other value. Empty means "no method restriction". + MethodAllow []string + + // MethodDeny removes specific "method" values even if otherwise allowed. + // Applied as defense in depth alongside MethodAllow. + MethodDeny []string + + // ParamReject lists parameters that must not be supplied and are removed + // from the advertised schema (e.g. cross-repo target parameters). + ParamReject []string + + // QueryGuard rejects a "query" parameter that contains a repo:, org:, or + // user: qualifier, which would otherwise escape the bound context. + QueryGuard bool + + // Description replaces the tool's advertised description so the surface + // reads as purpose-built for the bound context rather than as a generic + // tool with parameters removed. Required for every admitted tool. + Description string + + // Title optionally overrides the tool's human-facing display title. + Title string +} + +// Manifest is the curated interface spec for one scoped mode: the exact set of +// tools the mode exposes, keyed by canonical tool name. +type Manifest struct { + Kind Kind + Admit map[string]ToolBinding +} + +// ManifestFor returns the manifest for a scoped kind. +func ManifestFor(kind Kind) (Manifest, bool) { + m, ok := manifests[kind] + return m, ok +} + +// bindRepo binds the {owner, repo} pair shared by every repository-targeted +// tool. +var bindRepo = map[string]ctxKey{"owner": keyOwner, "repo": keyRepo} + +// bindPull binds {owner, repo, pullNumber} for tools whose subject is the +// bound pull request. +var bindPull = map[string]ctxKey{"owner": keyOwner, "repo": keyRepo, "pullNumber": keyPullNumber} + +// bindProject binds {owner, owner_type, project_number} for project-native +// tools. +var bindProject = map[string]ctxKey{"owner": keyOwner, "owner_type": keyOwnerType, "project_number": keyProjectNumber} + +var manifests = map[Kind]Manifest{ + KindRepo: repoManifest, + KindPullRequest: pullRequestManifest, + KindProject: projectManifest, +} + +// repoManifest is the "single repository" surface: file, branch, commit, issue, +// and pull request operations confined to one {owner, repo}. +var repoManifest = Manifest{ + Kind: KindRepo, + Admit: map[string]ToolBinding{ + // Files & contents. + "get_file_contents": { + Bind: bindRepo, + Description: "Read a file's contents or list a directory in {{.RepoRef}}.", + }, + "create_or_update_file": { + Bind: bindRepo, + Description: "Create a new file or update an existing file in {{.RepoRef}}.", + }, + "delete_file": { + Bind: bindRepo, + Description: "Delete a file from {{.RepoRef}}.", + }, + "push_files": { + Bind: bindRepo, + Description: "Commit and push multiple file changes to a branch in {{.RepoRef}} in a single operation.", + }, + // Branches & history. + "list_branches": { + Bind: bindRepo, + Description: "List the branches in {{.RepoRef}}.", + }, + "create_branch": { + Bind: bindRepo, + Description: "Create a new branch in {{.RepoRef}}.", + }, + "list_commits": { + Bind: bindRepo, + Description: "List commits on a branch of {{.RepoRef}}.", + }, + "get_commit": { + Bind: bindRepo, + Description: "Get the details and diff of a single commit in {{.RepoRef}}.", + }, + // Issues. + "list_issues": { + Bind: bindRepo, + Description: "List issues in {{.RepoRef}}.", + }, + "issue_read": { + Bind: bindRepo, + Description: "Read an issue in {{.RepoRef}}: its details, comments, sub-issues, or labels.", + }, + "create_issue": { + Bind: bindRepo, + Description: "Open a new issue in {{.RepoRef}}.", + }, + "add_issue_comment": { + Bind: bindRepo, + Description: "Add a comment to an issue or pull request in {{.RepoRef}}.", + }, + "search_issues": { + Bind: bindRepo, + QueryGuard: true, + Description: "Search issues within {{.RepoRef}}.", + }, + // Pull requests. + "list_pull_requests": { + Bind: bindRepo, + Description: "List pull requests in {{.RepoRef}}.", + }, + "pull_request_read": { + Bind: bindRepo, + Description: "Read a pull request in {{.RepoRef}}: its details, diff, changed files, commits, reviews, comments, or status.", + }, + "create_pull_request": { + Bind: bindRepo, + Description: "Open a new pull request in {{.RepoRef}}.", + }, + "search_pull_requests": { + Bind: bindRepo, + QueryGuard: true, + Description: "Search pull requests within {{.RepoRef}}.", + }, + }, +} + +// pullRequestManifest is the "single pull request" surface: every tool whose +// subject is the bound PR, plus a couple of repository reads for context. +var pullRequestManifest = Manifest{ + Kind: KindPullRequest, + Admit: map[string]ToolBinding{ + "pull_request_read": { + Bind: bindPull, + Description: "Read pull request {{.PullRef}}: its details, diff, changed files, commits, reviews, review comments, or status.", + }, + "update_pull_request_title": { + Bind: bindPull, + Description: "Update the title of pull request {{.PullRef}}.", + }, + "update_pull_request_body": { + Bind: bindPull, + Description: "Update the description of pull request {{.PullRef}}.", + }, + "update_pull_request_state": { + Bind: bindPull, + Description: "Open or close pull request {{.PullRef}}.", + }, + "update_pull_request_draft_state": { + Bind: bindPull, + Description: "Mark pull request {{.PullRef}} as a draft or as ready for review.", + }, + "update_pull_request_branch": { + Bind: bindPull, + Description: "Update the branch of pull request {{.PullRef}} with the latest changes from its base branch.", + }, + "merge_pull_request": { + Bind: bindPull, + Description: "Merge pull request {{.PullRef}}.", + }, + "request_pull_request_reviewers": { + Bind: bindPull, + Description: "Request reviewers on pull request {{.PullRef}}.", + }, + "request_copilot_review": { + Bind: bindPull, + Description: "Request a GitHub Copilot review on pull request {{.PullRef}}.", + }, + "create_pull_request_review": { + Bind: bindPull, + Description: "Create a review on pull request {{.PullRef}}.", + }, + "add_pull_request_review_comment": { + Bind: bindPull, + Description: "Add an inline review comment to a line of the diff of pull request {{.PullRef}}.", + }, + "add_comment_to_pending_review": { + Bind: bindPull, + Description: "Add a comment to your pending review on pull request {{.PullRef}}.", + }, + "add_reply_to_pull_request_comment": { + Bind: bindPull, + Description: "Reply to an existing review comment on pull request {{.PullRef}}.", + }, + "submit_pending_pull_request_review": { + Bind: bindPull, + Description: "Submit your pending review on pull request {{.PullRef}}.", + }, + "delete_pending_pull_request_review": { + Bind: bindPull, + Description: "Discard your pending review on pull request {{.PullRef}}.", + }, + "pull_request_review_write": { + Bind: bindPull, + // Thread operations address review threads by global node ID, + // which is not constrained to this pull request, so they are + // removed from the advertised method enum and rejected at runtime. + // threadId only feeds those operations, so it is removed too. + MethodDeny: []string{"resolve_thread", "unresolve_thread"}, + ParamReject: []string{"threadId"}, + Description: "Create, submit, or discard a pending review on pull request {{.PullRef}}.", + }, + // Repository reads that give a reviewer file and commit context. + "get_file_contents": { + Bind: bindRepo, + Description: "Read a file's contents or list a directory in {{.RepoRef}}, the repository of pull request {{.PullRef}}.", + }, + "get_commit": { + Bind: bindRepo, + Description: "Get the details and diff of a single commit in {{.RepoRef}}, the repository of pull request {{.PullRef}}.", + }, + }, +} + +// projectManifest is the "single project" surface: the project-native read and +// write operations for one ProjectsV2 project. Cross-project enumeration and +// project creation are removed from the method enums. +var projectManifest = Manifest{ + Kind: KindProject, + Admit: map[string]ToolBinding{ + "projects_get": { + Bind: bindProject, + // get_project_status_update addresses a status update by global id, + // which is not constrained to this project; status_update_id only + // feeds that method, so it is removed from the schema too. + MethodAllow: []string{"get_project", "get_project_field", "get_project_item"}, + ParamReject: []string{"status_update_id"}, + Description: "Read {{.ProjectRef}}: the project itself, one of its fields, or one of its items.", + }, + "projects_list": { + Bind: bindProject, + // list_projects enumerates every project owned by the owner, + // escaping the single bound project. + MethodAllow: []string{"list_project_fields", "list_project_items", "list_project_status_updates"}, + Description: "List the fields, items, or status updates of {{.ProjectRef}}.", + }, + "projects_write": { + Bind: bindProject, + // create_project creates a new project under the owner, outside the + // bound project. + MethodAllow: []string{"add_project_item", "update_project_item", "delete_project_item", "create_project_status_update", "create_iteration_field"}, + Description: "Manage {{.ProjectRef}}: add, update, or remove items, post status updates, and create iteration fields.", + }, + }, +} + +// ServerTitle returns a human-facing server title for the bound context, used +// to present the scoped server as a distinct product. +func (c Context) ServerTitle() string { + switch c.Kind { + case KindRepo: + return fmt.Sprintf("GitHub Repository · %s/%s", c.Owner, c.Repo) + case KindPullRequest: + return fmt.Sprintf("GitHub Pull Request · %s/%s#%d", c.Owner, c.Repo, c.PullNumber) + case KindProject: + return fmt.Sprintf("GitHub Project · %s/%d (%s)", c.Owner, c.ProjectNumber, c.OwnerType) + default: + return "GitHub" + } +} + +// ServerInstructions returns a one-line description of the bound context for +// the server's instructions, stating that the context is fixed. +func (c Context) ServerInstructions() string { + switch c.Kind { + case KindRepo: + return fmt.Sprintf("This server operates only on the %s/%s repository. The repository is fixed; tools act on it automatically and do not accept an owner or repo.", c.Owner, c.Repo) + case KindPullRequest: + return fmt.Sprintf("This server operates only on pull request %s/%s#%d. The repository and pull request are fixed; tools act on them automatically.", c.Owner, c.Repo, c.PullNumber) + case KindProject: + return fmt.Sprintf("This server operates only on project number %d owned by %s. The project is fixed; tools act on it automatically.", c.ProjectNumber, c.Owner) + default: + return "" + } +} diff --git a/pkg/binding/surface_test.go b/pkg/binding/surface_test.go new file mode 100644 index 0000000000..e086e3eac5 --- /dev/null +++ b/pkg/binding/surface_test.go @@ -0,0 +1,130 @@ +package binding_test + +import ( + "context" + "path/filepath" + "sort" + "testing" + + "github.com/github/github-mcp-server/internal/toolsnaps" + "github.com/github/github-mcp-server/pkg/binding" + "github.com/github/github-mcp-server/pkg/github" + "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/google/jsonschema-go/jsonschema" + "github.com/stretchr/testify/require" +) + +func scopeCases(t *testing.T) map[binding.Kind]binding.Context { + t.Helper() + repo, err := binding.NewRepoContext("octocat", "hello-world") + require.NoError(t, err) + pull, err := binding.NewPullRequestContext("octocat", "hello-world", 42) + require.NoError(t, err) + project, err := binding.NewProjectContext("org", "octocat", 7) + require.NoError(t, err) + return map[binding.Kind]binding.Context{ + binding.KindRepo: repo, + binding.KindPullRequest: pull, + binding.KindProject: project, + } +} + +// scopedTools builds the scoped surface through the same path the server uses, +// so feature-flag variants are deduplicated exactly as an operator would see +// them by default. +func scopedTools(t *testing.T, ctx binding.Context) []inventory.ServerTool { + t.Helper() + th, _ := translations.TranslationHelper() + featureSet := github.ResolveFeatureFlags(nil, false) + checker := func(_ context.Context, flag string) (bool, error) { return featureSet[flag], nil } + + builder, err := github.NewScopedInventory(th, ctx) + require.NoError(t, err) + inv, err := builder. + WithToolsets([]string{"all"}). + WithReadOnly(false). + WithServerInstructions(). + WithFeatureChecker(checker). + Build() + require.NoError(t, err) + return inv.ToolsForRegistration(context.Background()) +} + +// TestManifestsAdmitOnlyRealTools is the fail-closed guard: every tool a +// manifest admits must exist in the full universe and carry a bespoke +// description. A renamed or removed tool fails here rather than silently +// dropping out of a scoped surface. +func TestManifestsAdmitOnlyRealTools(t *testing.T) { + th, _ := translations.TranslationHelper() + universe := map[string]bool{} + for _, st := range github.AllTools(th) { + universe[st.Tool.Name] = true + } + + for _, kind := range []binding.Kind{binding.KindRepo, binding.KindPullRequest, binding.KindProject} { + m, ok := binding.ManifestFor(kind) + require.Truef(t, ok, "no manifest for %q", kind) + require.NotEmptyf(t, m.Admit, "%q manifest admits no tools", kind) + for name, tb := range m.Admit { + require.Truef(t, universe[name], "%s manifest admits unknown tool %q", kind, name) + require.NotEmptyf(t, tb.Description, "%s tool %q must carry a bespoke description", kind, name) + } + } +} + +// TestApplyToolsValidatesBindings exercises the schema transform against the +// real tool schemas. ApplyTools fails if a bound or method-restricted parameter +// no longer exists, so this catches upstream schema changes that would break a +// scoped surface. +func TestApplyToolsValidatesBindings(t *testing.T) { + th, _ := translations.TranslationHelper() + universe := github.AllTools(th) + for kind, ctx := range scopeCases(t) { + _, err := binding.ApplyTools(universe, ctx) + require.NoErrorf(t, err, "ApplyTools failed for %s", kind) + } +} + +// TestScopedSurfaceHidesContextParams asserts that no bound or rejected +// parameter survives in any advertised schema: the scoped surface must look +// native, with the context-identifying fields absent rather than merely +// ignored. +func TestScopedSurfaceHidesContextParams(t *testing.T) { + for kind, ctx := range scopeCases(t) { + m, _ := binding.ManifestFor(kind) + tools := scopedTools(t, ctx) + require.NotEmptyf(t, tools, "%s surface is empty", kind) + for _, st := range tools { + tb := m.Admit[st.Tool.Name] + schema := st.Tool.InputSchema.(*jsonschema.Schema) + for param := range tb.Bind { + require.NotContainsf(t, schema.Properties, param, "%s/%s still advertises bound param %q", kind, st.Tool.Name, param) + } + for _, param := range tb.ParamReject { + require.NotContainsf(t, schema.Properties, param, "%s/%s still advertises rejected param %q", kind, st.Tool.Name, param) + } + } + } +} + +// TestScopedToolsnaps locks the advertised schema of every tool on every scoped +// surface, plus the membership of each surface, under per-surface snapshot +// subfolders (pkg/binding/__toolsnaps__/{repo,pull_request,project}/). Any +// change to a shared tool's schema, or to a manifest, shows up here and in the +// mcp-diff workflow. Run with UPDATE_TOOLSNAPS=true to regenerate. +func TestScopedToolsnaps(t *testing.T) { + for kind, ctx := range scopeCases(t) { + tools := scopedTools(t, ctx) + require.NotEmptyf(t, tools, "%s surface is empty", kind) + + names := make([]string, 0, len(tools)) + for _, st := range tools { + names = append(names, st.Tool.Name) + snap := filepath.Join(string(kind), st.Tool.Name) + require.NoError(t, toolsnaps.Test(snap, st.Tool)) + } + sort.Strings(names) + require.NoError(t, toolsnaps.Test(filepath.Join(string(kind), "_surface"), names)) + } +} diff --git a/pkg/github/inventory.go b/pkg/github/inventory.go index 38c936d862..4f77917513 100644 --- a/pkg/github/inventory.go +++ b/pkg/github/inventory.go @@ -1,6 +1,7 @@ package github import ( + "github.com/github/github-mcp-server/pkg/binding" "github.com/github/github-mcp-server/pkg/inventory" "github.com/github/github-mcp-server/pkg/translations" ) @@ -16,3 +17,21 @@ func NewInventory(t translations.TranslationHelperFunc) *inventory.Builder { SetResources(AllResources(t)). SetPrompts(AllPrompts(t)) } + +// NewScopedInventory creates an Inventory builder for a context-scoped server +// mode (repo, pull_request, or project). The full tool universe is transformed +// by the binding layer into the bespoke surface for the bound context before +// it reaches the builder, so the manifest is the only thing that decides which +// tools appear and how. All other builder configuration (read-only, feature +// flags, toolsets) still applies on top of the scoped set, exactly as for +// NewInventory. +func NewScopedInventory(t translations.TranslationHelperFunc, ctx binding.Context) (*inventory.Builder, error) { + tools, err := binding.ApplyTools(AllTools(t), ctx) + if err != nil { + return nil, err + } + return inventory.NewBuilder(). + SetTools(tools). + SetResources(binding.ApplyResources(AllResources(t), ctx)). + SetPrompts(binding.ApplyPrompts(AllPrompts(t), ctx)), nil +} diff --git a/pkg/github/server.go b/pkg/github/server.go index 7ec5837c3a..07a62f3468 100644 --- a/pkg/github/server.go +++ b/pkg/github/server.go @@ -8,6 +8,7 @@ import ( "strings" "time" + "github.com/github/github-mcp-server/pkg/binding" gherrors "github.com/github/github-mcp-server/pkg/errors" "github.com/github/github-mcp-server/pkg/inventory" "github.com/github/github-mcp-server/pkg/octicons" @@ -68,6 +69,13 @@ type MCPServerConfig struct { // This is used for PAT scope filtering where we can't issue scope challenges. TokenScopes []string + // Scope, when non-nil, binds the server to a fixed GitHub context (a + // repository, pull request, or project). The advertised tool surface is + // transformed into the bespoke surface for that context: context + // parameters are removed from tool schemas and injected server-side, and + // only tools admitted by the context's manifest are exposed. + Scope *binding.Context + // Additional server options to apply ServerOptions []MCPServerOption } @@ -75,9 +83,22 @@ type MCPServerConfig struct { type MCPServerOption func(*mcp.ServerOptions) func NewMCPServer(ctx context.Context, cfg *MCPServerConfig, deps ToolDependencies, inv *inventory.Inventory, middleware ...mcp.Middleware) (*mcp.Server, error) { + // Present a bespoke identity when the server is bound to a fixed context, + // so a scoped server reads as a purpose-built product rather than the full + // GitHub server. The context instructions are prepended to the generated + // toolset instructions. + instructions := inv.Instructions() + serverTitle := cfg.Translator("SERVER_TITLE", "GitHub MCP Server") + if cfg.Scope != nil { + serverTitle = cfg.Scope.ServerTitle() + if scopeInstructions := cfg.Scope.ServerInstructions(); scopeInstructions != "" { + instructions = strings.TrimSpace(scopeInstructions + "\n\n" + instructions) + } + } + // Create the MCP server serverOpts := &mcp.ServerOptions{ - Instructions: inv.Instructions(), + Instructions: instructions, Logger: cfg.Logger, CompletionHandler: CompletionsHandler(deps.GetClient), } @@ -87,7 +108,7 @@ func NewMCPServer(ctx context.Context, cfg *MCPServerConfig, deps ToolDependenci o(serverOpts) } - ghServer := NewServer(cfg.Version, cfg.Translator("SERVER_NAME", "github-mcp-server"), cfg.Translator("SERVER_TITLE", "GitHub MCP Server"), serverOpts) + ghServer := NewServer(cfg.Version, cfg.Translator("SERVER_NAME", "github-mcp-server"), serverTitle, serverOpts) // Add middlewares. Order matters - for example, the error context middleware should be applied last so that it runs FIRST (closest to the handler) to ensure all errors are captured, // and any middleware that needs to read or modify the context should be before it. diff --git a/script/print-mcp-diff-configs/main.go b/script/print-mcp-diff-configs/main.go index 421c9fce41..b59efafdd7 100644 --- a/script/print-mcp-diff-configs/main.go +++ b/script/print-mcp-diff-configs/main.go @@ -64,6 +64,11 @@ type settings struct { readOnly bool insiders bool lockdown bool + // scope is a complete scoped-mode CLI fragment (e.g. + // "--repository=octocat/hello-world"). Scoped modes are stdio-only and have + // no X-MCP-* header equivalent yet, so entries that set it are skipped by + // the http-headers transport. + scope string } const httpServerURL = "http://localhost:8082/mcp" @@ -82,6 +87,11 @@ func main() { } case "http-headers": for _, e := range entries { + // Scoped modes are stdio-only; there is no header transport for + // them yet, so they are not part of the http-headers matrix. + if e.settings.scope != "" { + continue + } h := e.settings.toHeaders() if h == nil { h = map[string]string{} @@ -139,6 +149,15 @@ func baseEntries() []baseEntry { toolsets: "repos", features: firstFeatureFlag(), }}, + // Context-scoped server modes (stdio only). These bind the server to a + // single repository, pull request, or project and expose the bespoke + // scoped tool surface for that context. Including them here ensures any + // change to a shared tool's schema is diffed on every scoped surface, + // not just the full server. + {name: "scope-repository", settings: settings{scope: "--repository=octocat/hello-world"}}, + {name: "scope-repository+read-only", settings: settings{scope: "--repository=octocat/hello-world", readOnly: true}}, + {name: "scope-pull-request", settings: settings{scope: "--pull-request=octocat/hello-world#42"}}, + {name: "scope-project", settings: settings{scope: "--project=orgs/octocat/7"}}, } flags := append([]string(nil), github.AllowedFeatureFlags...) @@ -154,6 +173,9 @@ func baseEntries() []baseEntry { func (s settings) toArgs() string { var parts []string + if s.scope != "" { + parts = append(parts, s.scope) + } if s.toolsets != "" { parts = append(parts, "--toolsets="+s.toolsets) }