From 70d8c435aecaac933be2c0279f825004a1dc4453 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Mon, 15 Jun 2026 13:46:53 +0200 Subject: [PATCH 1/2] spike: context-scoped MCP server modes (repo / pull-request / project) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prototype a binding layer that pins the MCP server to a single GitHub context so it presents a bespoke, purpose-built tool surface rather than a reduced copy of the full server. A new `pkg/binding` package transforms the tool universe for one of three scopes — a repository, a pull request, or a ProjectsV2 project. For each admitted tool it: - removes the context-identifying params (owner, repo, pullNumber, ...) from the advertised input schema and injects the fixed values at call time; - narrows the `method` enum to the operations the scope supports, pruning disallowed values from the schema (not just rejecting them at runtime); - rewrites the tool description so the surface reads as bespoke; - enforces the boundary in the handler: caller-supplied fixed/rejected params are refused, denied methods are blocked, and scoped search queries that could escape the bound context (cross-context qualifiers or boolean grouping) are rejected. Membership is an explicit per-mode manifest (fail-closed): a new server tool is invisible to a scoped surface until it is deliberately admitted. Wiring: `NewScopedInventory` pre-transforms the universe before the existing inventory filter pipeline (read-only, feature flags, PAT scopes still apply); `--repository` / `--pull-request` / `--project` stdio flags select the scope; the scoped server advertises a bespoke title and instructions. Validation: adversarial + singleton-safety unit tests, fail-closed manifest coverage, and per-surface toolsnaps under `pkg/binding/__toolsnaps__/{repo,pull_request,project}/` so tool changes must be re-wired into every surface. The mcp-diff config generator gains scoped stdio entries so the diff workflow tracks these surfaces too. Deferred: HTTP scoped roots/middleware, scoped resources + prompts, and a combined multi-project mode. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cmd/github-mcp-server/main.go | 57 ++++ internal/ghmcp/server.go | 41 ++- .../__toolsnaps__/project/_surface.snap | 5 + .../__toolsnaps__/project/projects_get.snap | 40 +++ .../__toolsnaps__/project/projects_list.snap | 48 +++ .../__toolsnaps__/project/projects_write.snap | 121 +++++++ .../__toolsnaps__/pull_request/_surface.snap | 11 + .../add_comment_to_pending_review.snap | 57 ++++ .../add_reply_to_pull_request_comment.snap | 24 ++ .../pull_request/get_commit.snap | 41 +++ .../pull_request/get_file_contents.snap | 26 ++ .../pull_request/merge_pull_request.snap | 41 +++ .../pull_request/pull_request_read.snap | 46 +++ .../pull_request_review_write.snap | 41 +++ .../pull_request/request_copilot_review.snap | 23 ++ .../update_pull_request_branch.snap | 16 + pkg/binding/__toolsnaps__/repo/_surface.snap | 18 ++ .../__toolsnaps__/repo/add_issue_comment.snap | 24 ++ .../__toolsnaps__/repo/create_branch.snap | 23 ++ .../repo/create_or_update_file.snap | 38 +++ .../repo/create_pull_request.snap | 41 +++ .../__toolsnaps__/repo/delete_file.snap | 30 ++ .../__toolsnaps__/repo/get_commit.snap | 41 +++ .../__toolsnaps__/repo/get_file_contents.snap | 26 ++ .../__toolsnaps__/repo/issue_read.snap | 42 +++ .../__toolsnaps__/repo/list_branches.snap | 24 ++ .../__toolsnaps__/repo/list_commits.snap | 44 +++ .../__toolsnaps__/repo/list_issues.snap | 59 ++++ .../repo/list_pull_requests.snap | 59 ++++ .../__toolsnaps__/repo/pull_request_read.snap | 51 +++ .../__toolsnaps__/repo/push_files.snap | 47 +++ .../__toolsnaps__/repo/search_issues.snap | 56 ++++ .../repo/search_pull_requests.snap | 56 ++++ pkg/binding/apply.go | 49 +++ pkg/binding/bind.go | 275 ++++++++++++++++ pkg/binding/bind_test.go | 226 +++++++++++++ pkg/binding/context.go | 197 +++++++++++ pkg/binding/manifest.go | 305 ++++++++++++++++++ pkg/binding/surface_test.go | 130 ++++++++ pkg/github/inventory.go | 19 ++ pkg/github/server.go | 25 +- script/print-mcp-diff-configs/main.go | 22 ++ 42 files changed, 2555 insertions(+), 10 deletions(-) create mode 100644 pkg/binding/__toolsnaps__/project/_surface.snap create mode 100644 pkg/binding/__toolsnaps__/project/projects_get.snap create mode 100644 pkg/binding/__toolsnaps__/project/projects_list.snap create mode 100644 pkg/binding/__toolsnaps__/project/projects_write.snap create mode 100644 pkg/binding/__toolsnaps__/pull_request/_surface.snap create mode 100644 pkg/binding/__toolsnaps__/pull_request/add_comment_to_pending_review.snap create mode 100644 pkg/binding/__toolsnaps__/pull_request/add_reply_to_pull_request_comment.snap create mode 100644 pkg/binding/__toolsnaps__/pull_request/get_commit.snap create mode 100644 pkg/binding/__toolsnaps__/pull_request/get_file_contents.snap create mode 100644 pkg/binding/__toolsnaps__/pull_request/merge_pull_request.snap create mode 100644 pkg/binding/__toolsnaps__/pull_request/pull_request_read.snap create mode 100644 pkg/binding/__toolsnaps__/pull_request/pull_request_review_write.snap create mode 100644 pkg/binding/__toolsnaps__/pull_request/request_copilot_review.snap create mode 100644 pkg/binding/__toolsnaps__/pull_request/update_pull_request_branch.snap create mode 100644 pkg/binding/__toolsnaps__/repo/_surface.snap create mode 100644 pkg/binding/__toolsnaps__/repo/add_issue_comment.snap create mode 100644 pkg/binding/__toolsnaps__/repo/create_branch.snap create mode 100644 pkg/binding/__toolsnaps__/repo/create_or_update_file.snap create mode 100644 pkg/binding/__toolsnaps__/repo/create_pull_request.snap create mode 100644 pkg/binding/__toolsnaps__/repo/delete_file.snap create mode 100644 pkg/binding/__toolsnaps__/repo/get_commit.snap create mode 100644 pkg/binding/__toolsnaps__/repo/get_file_contents.snap create mode 100644 pkg/binding/__toolsnaps__/repo/issue_read.snap create mode 100644 pkg/binding/__toolsnaps__/repo/list_branches.snap create mode 100644 pkg/binding/__toolsnaps__/repo/list_commits.snap create mode 100644 pkg/binding/__toolsnaps__/repo/list_issues.snap create mode 100644 pkg/binding/__toolsnaps__/repo/list_pull_requests.snap create mode 100644 pkg/binding/__toolsnaps__/repo/pull_request_read.snap create mode 100644 pkg/binding/__toolsnaps__/repo/push_files.snap create mode 100644 pkg/binding/__toolsnaps__/repo/search_issues.snap create mode 100644 pkg/binding/__toolsnaps__/repo/search_pull_requests.snap create mode 100644 pkg/binding/apply.go create mode 100644 pkg/binding/bind.go create mode 100644 pkg/binding/bind_test.go create mode 100644 pkg/binding/context.go create mode 100644 pkg/binding/manifest.go create mode 100644 pkg/binding/surface_test.go 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..59a7368a6f --- /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 this project: 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..da69ceea12 --- /dev/null +++ b/pkg/binding/__toolsnaps__/project/projects_list.snap @@ -0,0 +1,48 @@ +{ + "annotations": { + "readOnlyHint": true, + "title": "List GitHub Projects resources" + }, + "description": "List this project's fields, items, or status updates.", + "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..19777824a9 --- /dev/null +++ b/pkg/binding/__toolsnaps__/project/projects_write.snap @@ -0,0 +1,121 @@ +{ + "annotations": { + "destructiveHint": true, + "title": "Manage GitHub Projects" + }, + "description": "Manage this project: 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..c104baa1e5 --- /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 this pull request.", + "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..c200ff748d --- /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 this pull request.", + "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..4d51d0df06 --- /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 this pull request's repository.", + "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..95bf866c51 --- /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 this pull request's repository.", + "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..6b5f75fc3e --- /dev/null +++ b/pkg/binding/__toolsnaps__/pull_request/merge_pull_request.snap @@ -0,0 +1,41 @@ +{ + "annotations": { + "title": "Merge pull request" + }, + "description": "Merge this pull request.", + "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..c51d053803 --- /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 this pull request: 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..59fd92f185 --- /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 this pull request.", + "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..96c4f1897c --- /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 this pull request.", + "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..655426cbf4 --- /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 this pull request's branch 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..4f1ff45b33 --- /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 this repository.", + "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..2cb9a74a9a --- /dev/null +++ b/pkg/binding/__toolsnaps__/repo/create_branch.snap @@ -0,0 +1,23 @@ +{ + "annotations": { + "title": "Create branch" + }, + "description": "Create a new branch in this repository.", + "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..b95e775663 --- /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 this repository.", + "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..99bee65289 --- /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 this repository.", + "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..8abfef925a --- /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 this repository.", + "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..bfbd2a6a97 --- /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 this repository.", + "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..948c79d9e6 --- /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 this repository.", + "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..0b930c3ef9 --- /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 this repository: 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..a1384d7968 --- /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 this repository.", + "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..d3d933da0a --- /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 this repository.", + "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..1506ac773c --- /dev/null +++ b/pkg/binding/__toolsnaps__/repo/list_issues.snap @@ -0,0 +1,59 @@ +{ + "annotations": { + "readOnlyHint": true, + "title": "List issues" + }, + "description": "List issues in this repository.", + "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..755048a93c --- /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 this repository.", + "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..af281cf484 --- /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 this repository: 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..b41f698e57 --- /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 this repository 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..76f332d9c4 --- /dev/null +++ b/pkg/binding/__toolsnaps__/repo/search_issues.snap @@ -0,0 +1,56 @@ +{ + "annotations": { + "readOnlyHint": true, + "title": "Search issues" + }, + "description": "Search issues within this repository.", + "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..34e27c2330 --- /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 this repository.", + "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..55f1c58eba --- /dev/null +++ b/pkg/binding/bind.go @@ -0,0 +1,275 @@ +package binding + +import ( + "context" + "encoding/json" + "fmt" + "slices" + "strings" + + "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 != "" { + bound.Tool.Description = tb.Description + } + if tb.Title != "" { + bound.Tool.Title = tb.Title + } + bound.HandlerFunc = wrapHandler(st.HandlerFunc, tb, ctx) + return bound, 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..52956b3f2a --- /dev/null +++ b/pkg/binding/bind_test.go @@ -0,0 +1,226 @@ +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 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..42f80b0086 --- /dev/null +++ b/pkg/binding/context.go @@ -0,0 +1,197 @@ +// 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 +} + +// 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..d966820690 --- /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 this repository.", + }, + "create_or_update_file": { + Bind: bindRepo, + Description: "Create a new file or update an existing file in this repository.", + }, + "delete_file": { + Bind: bindRepo, + Description: "Delete a file from this repository.", + }, + "push_files": { + Bind: bindRepo, + Description: "Commit and push multiple file changes to a branch in this repository in a single operation.", + }, + // Branches & history. + "list_branches": { + Bind: bindRepo, + Description: "List the branches in this repository.", + }, + "create_branch": { + Bind: bindRepo, + Description: "Create a new branch in this repository.", + }, + "list_commits": { + Bind: bindRepo, + Description: "List commits on a branch of this repository.", + }, + "get_commit": { + Bind: bindRepo, + Description: "Get the details and diff of a single commit in this repository.", + }, + // Issues. + "list_issues": { + Bind: bindRepo, + Description: "List issues in this repository.", + }, + "issue_read": { + Bind: bindRepo, + Description: "Read an issue in this repository: its details, comments, sub-issues, or labels.", + }, + "create_issue": { + Bind: bindRepo, + Description: "Open a new issue in this repository.", + }, + "add_issue_comment": { + Bind: bindRepo, + Description: "Add a comment to an issue or pull request in this repository.", + }, + "search_issues": { + Bind: bindRepo, + QueryGuard: true, + Description: "Search issues within this repository.", + }, + // Pull requests. + "list_pull_requests": { + Bind: bindRepo, + Description: "List pull requests in this repository.", + }, + "pull_request_read": { + Bind: bindRepo, + Description: "Read a pull request in this repository: its details, diff, changed files, commits, reviews, comments, or status.", + }, + "create_pull_request": { + Bind: bindRepo, + Description: "Open a new pull request in this repository.", + }, + "search_pull_requests": { + Bind: bindRepo, + QueryGuard: true, + Description: "Search pull requests within this repository.", + }, + }, +} + +// 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 this pull request: its details, diff, changed files, commits, reviews, review comments, or status.", + }, + "update_pull_request_title": { + Bind: bindPull, + Description: "Update this pull request's title.", + }, + "update_pull_request_body": { + Bind: bindPull, + Description: "Update this pull request's description.", + }, + "update_pull_request_state": { + Bind: bindPull, + Description: "Open or close this pull request.", + }, + "update_pull_request_draft_state": { + Bind: bindPull, + Description: "Mark this pull request as a draft or as ready for review.", + }, + "update_pull_request_branch": { + Bind: bindPull, + Description: "Update this pull request's branch with the latest changes from its base branch.", + }, + "merge_pull_request": { + Bind: bindPull, + Description: "Merge this pull request.", + }, + "request_pull_request_reviewers": { + Bind: bindPull, + Description: "Request reviewers on this pull request.", + }, + "request_copilot_review": { + Bind: bindPull, + Description: "Request a GitHub Copilot review on this pull request.", + }, + "create_pull_request_review": { + Bind: bindPull, + Description: "Create a review on this pull request.", + }, + "add_pull_request_review_comment": { + Bind: bindPull, + Description: "Add an inline review comment to a line of this pull request's diff.", + }, + "add_comment_to_pending_review": { + Bind: bindPull, + Description: "Add a comment to your pending review on this pull request.", + }, + "add_reply_to_pull_request_comment": { + Bind: bindPull, + Description: "Reply to an existing review comment on this pull request.", + }, + "submit_pending_pull_request_review": { + Bind: bindPull, + Description: "Submit your pending review on this pull request.", + }, + "delete_pending_pull_request_review": { + Bind: bindPull, + Description: "Discard your pending review on this pull request.", + }, + "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 this pull request.", + }, + // 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 this pull request's repository.", + }, + "get_commit": { + Bind: bindRepo, + Description: "Get the details and diff of a single commit in this pull request's repository.", + }, + }, +} + +// 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 this project: 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 this project's fields, items, or status updates.", + }, + "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 this project: 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) } From 99b7d359853fb42536c90dd70855902d7e262105 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Mon, 15 Jun 2026 14:02:31 +0200 Subject: [PATCH 2/2] Render resource-specific scoped tool descriptions Scoped tool descriptions previously used generic relative phrasing ("this repository" / "this pull request" / "this project") that never named the bound resource. Render each manifest description as a text/template against the bound Context so the advertised surface names the concrete resource it operates on (e.g. octocat/hello-world#42), reinforcing the bespoke, purpose-built feel. - Add RepoRef/PullRef/ProjectRef helpers to Context. - Render Description templates in bindTool (missingkey=error, fail loud on parse/exec error; non-template descriptions pass through unchanged). - Rewrite repo, pull_request, and project manifest descriptions to name the resource via RepoRef/PullRef/ProjectRef. - Regenerate per-surface toolsnaps; add tests locking rendered output and malformed-template failure. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../__toolsnaps__/project/projects_get.snap | 2 +- .../__toolsnaps__/project/projects_list.snap | 2 +- .../__toolsnaps__/project/projects_write.snap | 2 +- .../add_comment_to_pending_review.snap | 2 +- .../add_reply_to_pull_request_comment.snap | 2 +- .../pull_request/get_commit.snap | 2 +- .../pull_request/get_file_contents.snap | 2 +- .../pull_request/merge_pull_request.snap | 2 +- .../pull_request/pull_request_read.snap | 2 +- .../pull_request_review_write.snap | 2 +- .../pull_request/request_copilot_review.snap | 2 +- .../update_pull_request_branch.snap | 2 +- .../__toolsnaps__/repo/add_issue_comment.snap | 2 +- .../__toolsnaps__/repo/create_branch.snap | 2 +- .../repo/create_or_update_file.snap | 2 +- .../repo/create_pull_request.snap | 2 +- .../__toolsnaps__/repo/delete_file.snap | 2 +- .../__toolsnaps__/repo/get_commit.snap | 2 +- .../__toolsnaps__/repo/get_file_contents.snap | 2 +- .../__toolsnaps__/repo/issue_read.snap | 2 +- .../__toolsnaps__/repo/list_branches.snap | 2 +- .../__toolsnaps__/repo/list_commits.snap | 2 +- .../__toolsnaps__/repo/list_issues.snap | 2 +- .../repo/list_pull_requests.snap | 2 +- .../__toolsnaps__/repo/pull_request_read.snap | 2 +- .../__toolsnaps__/repo/push_files.snap | 2 +- .../__toolsnaps__/repo/search_issues.snap | 2 +- .../repo/search_pull_requests.snap | 2 +- pkg/binding/bind.go | 28 ++++++- pkg/binding/bind_test.go | 18 +++++ pkg/binding/context.go | 19 +++++ pkg/binding/manifest.go | 76 +++++++++---------- 32 files changed, 130 insertions(+), 67 deletions(-) diff --git a/pkg/binding/__toolsnaps__/project/projects_get.snap b/pkg/binding/__toolsnaps__/project/projects_get.snap index 59a7368a6f..0a6163f234 100644 --- a/pkg/binding/__toolsnaps__/project/projects_get.snap +++ b/pkg/binding/__toolsnaps__/project/projects_get.snap @@ -3,7 +3,7 @@ "readOnlyHint": true, "title": "Get details of GitHub Projects resources" }, - "description": "Read this project: the project itself, one of its fields, or one of its items.", + "description": "Read project #7 owned by octocat: the project itself, one of its fields, or one of its items.", "inputSchema": { "properties": { "field_id": { diff --git a/pkg/binding/__toolsnaps__/project/projects_list.snap b/pkg/binding/__toolsnaps__/project/projects_list.snap index da69ceea12..7b886396e0 100644 --- a/pkg/binding/__toolsnaps__/project/projects_list.snap +++ b/pkg/binding/__toolsnaps__/project/projects_list.snap @@ -3,7 +3,7 @@ "readOnlyHint": true, "title": "List GitHub Projects resources" }, - "description": "List this project's fields, items, or status updates.", + "description": "List the fields, items, or status updates of project #7 owned by octocat.", "inputSchema": { "properties": { "after": { diff --git a/pkg/binding/__toolsnaps__/project/projects_write.snap b/pkg/binding/__toolsnaps__/project/projects_write.snap index 19777824a9..5f439e48c5 100644 --- a/pkg/binding/__toolsnaps__/project/projects_write.snap +++ b/pkg/binding/__toolsnaps__/project/projects_write.snap @@ -3,7 +3,7 @@ "destructiveHint": true, "title": "Manage GitHub Projects" }, - "description": "Manage this project: add, update, or remove items, post status updates, and create iteration fields.", + "description": "Manage project #7 owned by octocat: add, update, or remove items, post status updates, and create iteration fields.", "inputSchema": { "properties": { "body": { 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 index c104baa1e5..67b327065f 100644 --- a/pkg/binding/__toolsnaps__/pull_request/add_comment_to_pending_review.snap +++ b/pkg/binding/__toolsnaps__/pull_request/add_comment_to_pending_review.snap @@ -2,7 +2,7 @@ "annotations": { "title": "Add review comment to the requester's latest pending pull request review" }, - "description": "Add a comment to your pending review on this pull request.", + "description": "Add a comment to your pending review on pull request octocat/hello-world#42.", "inputSchema": { "properties": { "body": { 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 index c200ff748d..15d5a6107b 100644 --- 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 @@ -2,7 +2,7 @@ "annotations": { "title": "Add reply to pull request comment" }, - "description": "Reply to an existing review comment on this pull request.", + "description": "Reply to an existing review comment on pull request octocat/hello-world#42.", "inputSchema": { "properties": { "body": { diff --git a/pkg/binding/__toolsnaps__/pull_request/get_commit.snap b/pkg/binding/__toolsnaps__/pull_request/get_commit.snap index 4d51d0df06..005923773c 100644 --- a/pkg/binding/__toolsnaps__/pull_request/get_commit.snap +++ b/pkg/binding/__toolsnaps__/pull_request/get_commit.snap @@ -3,7 +3,7 @@ "readOnlyHint": true, "title": "Get commit details" }, - "description": "Get the details and diff of a single commit in this pull request's repository.", + "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": { diff --git a/pkg/binding/__toolsnaps__/pull_request/get_file_contents.snap b/pkg/binding/__toolsnaps__/pull_request/get_file_contents.snap index 95bf866c51..631a5690f5 100644 --- a/pkg/binding/__toolsnaps__/pull_request/get_file_contents.snap +++ b/pkg/binding/__toolsnaps__/pull_request/get_file_contents.snap @@ -3,7 +3,7 @@ "readOnlyHint": true, "title": "Get file or directory contents" }, - "description": "Read a file's contents or list a directory in this pull request's repository.", + "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": { diff --git a/pkg/binding/__toolsnaps__/pull_request/merge_pull_request.snap b/pkg/binding/__toolsnaps__/pull_request/merge_pull_request.snap index 6b5f75fc3e..9ac52bc5d4 100644 --- a/pkg/binding/__toolsnaps__/pull_request/merge_pull_request.snap +++ b/pkg/binding/__toolsnaps__/pull_request/merge_pull_request.snap @@ -2,7 +2,7 @@ "annotations": { "title": "Merge pull request" }, - "description": "Merge this pull request.", + "description": "Merge pull request octocat/hello-world#42.", "icons": [ { "mimeType": "image/png", diff --git a/pkg/binding/__toolsnaps__/pull_request/pull_request_read.snap b/pkg/binding/__toolsnaps__/pull_request/pull_request_read.snap index c51d053803..ac48ec32b2 100644 --- a/pkg/binding/__toolsnaps__/pull_request/pull_request_read.snap +++ b/pkg/binding/__toolsnaps__/pull_request/pull_request_read.snap @@ -3,7 +3,7 @@ "readOnlyHint": true, "title": "Get details for a single pull request" }, - "description": "Read this pull request: its details, diff, changed files, commits, reviews, review comments, or status.", + "description": "Read pull request octocat/hello-world#42: its details, diff, changed files, commits, reviews, review comments, or status.", "inputSchema": { "properties": { "after": { diff --git a/pkg/binding/__toolsnaps__/pull_request/pull_request_review_write.snap b/pkg/binding/__toolsnaps__/pull_request/pull_request_review_write.snap index 59fd92f185..f07f9c6f1d 100644 --- a/pkg/binding/__toolsnaps__/pull_request/pull_request_review_write.snap +++ b/pkg/binding/__toolsnaps__/pull_request/pull_request_review_write.snap @@ -2,7 +2,7 @@ "annotations": { "title": "Write operations (create, submit, delete) on pull request reviews" }, - "description": "Create, submit, or discard a pending review on this pull request.", + "description": "Create, submit, or discard a pending review on pull request octocat/hello-world#42.", "inputSchema": { "properties": { "body": { diff --git a/pkg/binding/__toolsnaps__/pull_request/request_copilot_review.snap b/pkg/binding/__toolsnaps__/pull_request/request_copilot_review.snap index 96c4f1897c..102125470f 100644 --- a/pkg/binding/__toolsnaps__/pull_request/request_copilot_review.snap +++ b/pkg/binding/__toolsnaps__/pull_request/request_copilot_review.snap @@ -2,7 +2,7 @@ "annotations": { "title": "Request Copilot review" }, - "description": "Request a GitHub Copilot review on this pull request.", + "description": "Request a GitHub Copilot review on pull request octocat/hello-world#42.", "icons": [ { "mimeType": "image/png", diff --git a/pkg/binding/__toolsnaps__/pull_request/update_pull_request_branch.snap b/pkg/binding/__toolsnaps__/pull_request/update_pull_request_branch.snap index 655426cbf4..4d86a259c4 100644 --- a/pkg/binding/__toolsnaps__/pull_request/update_pull_request_branch.snap +++ b/pkg/binding/__toolsnaps__/pull_request/update_pull_request_branch.snap @@ -2,7 +2,7 @@ "annotations": { "title": "Update pull request branch" }, - "description": "Update this pull request's branch with the latest changes from its base branch.", + "description": "Update the branch of pull request octocat/hello-world#42 with the latest changes from its base branch.", "inputSchema": { "properties": { "expectedHeadSha": { diff --git a/pkg/binding/__toolsnaps__/repo/add_issue_comment.snap b/pkg/binding/__toolsnaps__/repo/add_issue_comment.snap index 4f1ff45b33..811d8f0259 100644 --- a/pkg/binding/__toolsnaps__/repo/add_issue_comment.snap +++ b/pkg/binding/__toolsnaps__/repo/add_issue_comment.snap @@ -2,7 +2,7 @@ "annotations": { "title": "Add comment to issue or pull request" }, - "description": "Add a comment to an issue or pull request in this repository.", + "description": "Add a comment to an issue or pull request in octocat/hello-world.", "inputSchema": { "properties": { "body": { diff --git a/pkg/binding/__toolsnaps__/repo/create_branch.snap b/pkg/binding/__toolsnaps__/repo/create_branch.snap index 2cb9a74a9a..3b9a3dfa4a 100644 --- a/pkg/binding/__toolsnaps__/repo/create_branch.snap +++ b/pkg/binding/__toolsnaps__/repo/create_branch.snap @@ -2,7 +2,7 @@ "annotations": { "title": "Create branch" }, - "description": "Create a new branch in this repository.", + "description": "Create a new branch in octocat/hello-world.", "inputSchema": { "properties": { "branch": { diff --git a/pkg/binding/__toolsnaps__/repo/create_or_update_file.snap b/pkg/binding/__toolsnaps__/repo/create_or_update_file.snap index b95e775663..f378b7baf1 100644 --- a/pkg/binding/__toolsnaps__/repo/create_or_update_file.snap +++ b/pkg/binding/__toolsnaps__/repo/create_or_update_file.snap @@ -2,7 +2,7 @@ "annotations": { "title": "Create or update file" }, - "description": "Create a new file or update an existing file in this repository.", + "description": "Create a new file or update an existing file in octocat/hello-world.", "inputSchema": { "properties": { "branch": { diff --git a/pkg/binding/__toolsnaps__/repo/create_pull_request.snap b/pkg/binding/__toolsnaps__/repo/create_pull_request.snap index 99bee65289..8641553928 100644 --- a/pkg/binding/__toolsnaps__/repo/create_pull_request.snap +++ b/pkg/binding/__toolsnaps__/repo/create_pull_request.snap @@ -2,7 +2,7 @@ "annotations": { "title": "Open new pull request" }, - "description": "Open a new pull request in this repository.", + "description": "Open a new pull request in octocat/hello-world.", "inputSchema": { "properties": { "base": { diff --git a/pkg/binding/__toolsnaps__/repo/delete_file.snap b/pkg/binding/__toolsnaps__/repo/delete_file.snap index 8abfef925a..86f5d7c398 100644 --- a/pkg/binding/__toolsnaps__/repo/delete_file.snap +++ b/pkg/binding/__toolsnaps__/repo/delete_file.snap @@ -3,7 +3,7 @@ "destructiveHint": true, "title": "Delete file" }, - "description": "Delete a file from this repository.", + "description": "Delete a file from octocat/hello-world.", "inputSchema": { "properties": { "branch": { diff --git a/pkg/binding/__toolsnaps__/repo/get_commit.snap b/pkg/binding/__toolsnaps__/repo/get_commit.snap index bfbd2a6a97..80c1af4164 100644 --- a/pkg/binding/__toolsnaps__/repo/get_commit.snap +++ b/pkg/binding/__toolsnaps__/repo/get_commit.snap @@ -3,7 +3,7 @@ "readOnlyHint": true, "title": "Get commit details" }, - "description": "Get the details and diff of a single commit in this repository.", + "description": "Get the details and diff of a single commit in octocat/hello-world.", "inputSchema": { "properties": { "detail": { diff --git a/pkg/binding/__toolsnaps__/repo/get_file_contents.snap b/pkg/binding/__toolsnaps__/repo/get_file_contents.snap index 948c79d9e6..3b5de4609c 100644 --- a/pkg/binding/__toolsnaps__/repo/get_file_contents.snap +++ b/pkg/binding/__toolsnaps__/repo/get_file_contents.snap @@ -3,7 +3,7 @@ "readOnlyHint": true, "title": "Get file or directory contents" }, - "description": "Read a file's contents or list a directory in this repository.", + "description": "Read a file's contents or list a directory in octocat/hello-world.", "inputSchema": { "properties": { "path": { diff --git a/pkg/binding/__toolsnaps__/repo/issue_read.snap b/pkg/binding/__toolsnaps__/repo/issue_read.snap index 0b930c3ef9..705fb63523 100644 --- a/pkg/binding/__toolsnaps__/repo/issue_read.snap +++ b/pkg/binding/__toolsnaps__/repo/issue_read.snap @@ -3,7 +3,7 @@ "readOnlyHint": true, "title": "Get issue details" }, - "description": "Read an issue in this repository: its details, comments, sub-issues, or labels.", + "description": "Read an issue in octocat/hello-world: its details, comments, sub-issues, or labels.", "inputSchema": { "properties": { "issue_number": { diff --git a/pkg/binding/__toolsnaps__/repo/list_branches.snap b/pkg/binding/__toolsnaps__/repo/list_branches.snap index a1384d7968..1f70082f97 100644 --- a/pkg/binding/__toolsnaps__/repo/list_branches.snap +++ b/pkg/binding/__toolsnaps__/repo/list_branches.snap @@ -3,7 +3,7 @@ "readOnlyHint": true, "title": "List branches" }, - "description": "List the branches in this repository.", + "description": "List the branches in octocat/hello-world.", "inputSchema": { "properties": { "page": { diff --git a/pkg/binding/__toolsnaps__/repo/list_commits.snap b/pkg/binding/__toolsnaps__/repo/list_commits.snap index d3d933da0a..85d9750d20 100644 --- a/pkg/binding/__toolsnaps__/repo/list_commits.snap +++ b/pkg/binding/__toolsnaps__/repo/list_commits.snap @@ -3,7 +3,7 @@ "readOnlyHint": true, "title": "List commits" }, - "description": "List commits on a branch of this repository.", + "description": "List commits on a branch of octocat/hello-world.", "inputSchema": { "properties": { "author": { diff --git a/pkg/binding/__toolsnaps__/repo/list_issues.snap b/pkg/binding/__toolsnaps__/repo/list_issues.snap index 1506ac773c..907335c765 100644 --- a/pkg/binding/__toolsnaps__/repo/list_issues.snap +++ b/pkg/binding/__toolsnaps__/repo/list_issues.snap @@ -3,7 +3,7 @@ "readOnlyHint": true, "title": "List issues" }, - "description": "List issues in this repository.", + "description": "List issues in octocat/hello-world.", "inputSchema": { "properties": { "after": { diff --git a/pkg/binding/__toolsnaps__/repo/list_pull_requests.snap b/pkg/binding/__toolsnaps__/repo/list_pull_requests.snap index 755048a93c..c3bc8ab078 100644 --- a/pkg/binding/__toolsnaps__/repo/list_pull_requests.snap +++ b/pkg/binding/__toolsnaps__/repo/list_pull_requests.snap @@ -3,7 +3,7 @@ "readOnlyHint": true, "title": "List pull requests" }, - "description": "List pull requests in this repository.", + "description": "List pull requests in octocat/hello-world.", "inputSchema": { "properties": { "base": { diff --git a/pkg/binding/__toolsnaps__/repo/pull_request_read.snap b/pkg/binding/__toolsnaps__/repo/pull_request_read.snap index af281cf484..9ca01940dd 100644 --- a/pkg/binding/__toolsnaps__/repo/pull_request_read.snap +++ b/pkg/binding/__toolsnaps__/repo/pull_request_read.snap @@ -3,7 +3,7 @@ "readOnlyHint": true, "title": "Get details for a single pull request" }, - "description": "Read a pull request in this repository: its details, diff, changed files, commits, reviews, comments, or status.", + "description": "Read a pull request in octocat/hello-world: its details, diff, changed files, commits, reviews, comments, or status.", "inputSchema": { "properties": { "after": { diff --git a/pkg/binding/__toolsnaps__/repo/push_files.snap b/pkg/binding/__toolsnaps__/repo/push_files.snap index b41f698e57..e259eed720 100644 --- a/pkg/binding/__toolsnaps__/repo/push_files.snap +++ b/pkg/binding/__toolsnaps__/repo/push_files.snap @@ -2,7 +2,7 @@ "annotations": { "title": "Push files to repository" }, - "description": "Commit and push multiple file changes to a branch in this repository in a single operation.", + "description": "Commit and push multiple file changes to a branch in octocat/hello-world in a single operation.", "inputSchema": { "properties": { "branch": { diff --git a/pkg/binding/__toolsnaps__/repo/search_issues.snap b/pkg/binding/__toolsnaps__/repo/search_issues.snap index 76f332d9c4..2ba614d0b1 100644 --- a/pkg/binding/__toolsnaps__/repo/search_issues.snap +++ b/pkg/binding/__toolsnaps__/repo/search_issues.snap @@ -3,7 +3,7 @@ "readOnlyHint": true, "title": "Search issues" }, - "description": "Search issues within this repository.", + "description": "Search issues within octocat/hello-world.", "inputSchema": { "properties": { "order": { diff --git a/pkg/binding/__toolsnaps__/repo/search_pull_requests.snap b/pkg/binding/__toolsnaps__/repo/search_pull_requests.snap index 34e27c2330..b2064a2cb3 100644 --- a/pkg/binding/__toolsnaps__/repo/search_pull_requests.snap +++ b/pkg/binding/__toolsnaps__/repo/search_pull_requests.snap @@ -3,7 +3,7 @@ "readOnlyHint": true, "title": "Search pull requests" }, - "description": "Search pull requests within this repository.", + "description": "Search pull requests within octocat/hello-world.", "inputSchema": { "properties": { "order": { diff --git a/pkg/binding/bind.go b/pkg/binding/bind.go index 55f1c58eba..22d42ba0dd 100644 --- a/pkg/binding/bind.go +++ b/pkg/binding/bind.go @@ -6,6 +6,7 @@ import ( "fmt" "slices" "strings" + "text/template" "github.com/github/github-mcp-server/pkg/inventory" "github.com/google/jsonschema-go/jsonschema" @@ -31,7 +32,11 @@ func bindTool(st inventory.ServerTool, tb ToolBinding, ctx Context) (inventory.S bound := st // shallow struct copy; Tool is a value, so edits below are local bound.Tool.InputSchema = newSchema if tb.Description != "" { - bound.Tool.Description = 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 @@ -40,6 +45,27 @@ func bindTool(st inventory.ServerTool, tb ToolBinding, ctx Context) (inventory.S 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) { diff --git a/pkg/binding/bind_test.go b/pkg/binding/bind_test.go index 52956b3f2a..6bad466399 100644 --- a/pkg/binding/bind_test.go +++ b/pkg/binding/bind_test.go @@ -218,6 +218,24 @@ func TestNarrowEnum(t *testing.T) { 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) diff --git a/pkg/binding/context.go b/pkg/binding/context.go index 42f80b0086..1f0c7eba57 100644 --- a/pkg/binding/context.go +++ b/pkg/binding/context.go @@ -57,6 +57,25 @@ type Context struct { 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. diff --git a/pkg/binding/manifest.go b/pkg/binding/manifest.go index d966820690..a58082aa93 100644 --- a/pkg/binding/manifest.go +++ b/pkg/binding/manifest.go @@ -81,76 +81,76 @@ var repoManifest = Manifest{ // Files & contents. "get_file_contents": { Bind: bindRepo, - Description: "Read a file's contents or list a directory in this repository.", + 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 this repository.", + Description: "Create a new file or update an existing file in {{.RepoRef}}.", }, "delete_file": { Bind: bindRepo, - Description: "Delete a file from this repository.", + Description: "Delete a file from {{.RepoRef}}.", }, "push_files": { Bind: bindRepo, - Description: "Commit and push multiple file changes to a branch in this repository in a single operation.", + 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 this repository.", + Description: "List the branches in {{.RepoRef}}.", }, "create_branch": { Bind: bindRepo, - Description: "Create a new branch in this repository.", + Description: "Create a new branch in {{.RepoRef}}.", }, "list_commits": { Bind: bindRepo, - Description: "List commits on a branch of this repository.", + Description: "List commits on a branch of {{.RepoRef}}.", }, "get_commit": { Bind: bindRepo, - Description: "Get the details and diff of a single commit in this repository.", + Description: "Get the details and diff of a single commit in {{.RepoRef}}.", }, // Issues. "list_issues": { Bind: bindRepo, - Description: "List issues in this repository.", + Description: "List issues in {{.RepoRef}}.", }, "issue_read": { Bind: bindRepo, - Description: "Read an issue in this repository: its details, comments, sub-issues, or labels.", + Description: "Read an issue in {{.RepoRef}}: its details, comments, sub-issues, or labels.", }, "create_issue": { Bind: bindRepo, - Description: "Open a new issue in this repository.", + Description: "Open a new issue in {{.RepoRef}}.", }, "add_issue_comment": { Bind: bindRepo, - Description: "Add a comment to an issue or pull request in this repository.", + Description: "Add a comment to an issue or pull request in {{.RepoRef}}.", }, "search_issues": { Bind: bindRepo, QueryGuard: true, - Description: "Search issues within this repository.", + Description: "Search issues within {{.RepoRef}}.", }, // Pull requests. "list_pull_requests": { Bind: bindRepo, - Description: "List pull requests in this repository.", + Description: "List pull requests in {{.RepoRef}}.", }, "pull_request_read": { Bind: bindRepo, - Description: "Read a pull request in this repository: its details, diff, changed files, commits, reviews, comments, or status.", + 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 this repository.", + Description: "Open a new pull request in {{.RepoRef}}.", }, "search_pull_requests": { Bind: bindRepo, QueryGuard: true, - Description: "Search pull requests within this repository.", + Description: "Search pull requests within {{.RepoRef}}.", }, }, } @@ -162,63 +162,63 @@ var pullRequestManifest = Manifest{ Admit: map[string]ToolBinding{ "pull_request_read": { Bind: bindPull, - Description: "Read this pull request: its details, diff, changed files, commits, reviews, review comments, or status.", + Description: "Read pull request {{.PullRef}}: its details, diff, changed files, commits, reviews, review comments, or status.", }, "update_pull_request_title": { Bind: bindPull, - Description: "Update this pull request's title.", + Description: "Update the title of pull request {{.PullRef}}.", }, "update_pull_request_body": { Bind: bindPull, - Description: "Update this pull request's description.", + Description: "Update the description of pull request {{.PullRef}}.", }, "update_pull_request_state": { Bind: bindPull, - Description: "Open or close this pull request.", + Description: "Open or close pull request {{.PullRef}}.", }, "update_pull_request_draft_state": { Bind: bindPull, - Description: "Mark this pull request as a draft or as ready for review.", + Description: "Mark pull request {{.PullRef}} as a draft or as ready for review.", }, "update_pull_request_branch": { Bind: bindPull, - Description: "Update this pull request's branch with the latest changes from its base branch.", + Description: "Update the branch of pull request {{.PullRef}} with the latest changes from its base branch.", }, "merge_pull_request": { Bind: bindPull, - Description: "Merge this pull request.", + Description: "Merge pull request {{.PullRef}}.", }, "request_pull_request_reviewers": { Bind: bindPull, - Description: "Request reviewers on this pull request.", + Description: "Request reviewers on pull request {{.PullRef}}.", }, "request_copilot_review": { Bind: bindPull, - Description: "Request a GitHub Copilot review on this pull request.", + Description: "Request a GitHub Copilot review on pull request {{.PullRef}}.", }, "create_pull_request_review": { Bind: bindPull, - Description: "Create a review on this pull request.", + 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 this pull request's diff.", + 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 this pull request.", + 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 this pull request.", + Description: "Reply to an existing review comment on pull request {{.PullRef}}.", }, "submit_pending_pull_request_review": { Bind: bindPull, - Description: "Submit your pending review on this pull request.", + Description: "Submit your pending review on pull request {{.PullRef}}.", }, "delete_pending_pull_request_review": { Bind: bindPull, - Description: "Discard your pending review on this pull request.", + Description: "Discard your pending review on pull request {{.PullRef}}.", }, "pull_request_review_write": { Bind: bindPull, @@ -228,16 +228,16 @@ var pullRequestManifest = Manifest{ // 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 this pull request.", + 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 this pull request's repository.", + 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 this pull request's repository.", + Description: "Get the details and diff of a single commit in {{.RepoRef}}, the repository of pull request {{.PullRef}}.", }, }, } @@ -255,21 +255,21 @@ var projectManifest = Manifest{ // 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 this project: the project itself, one of its fields, or one of its items.", + 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 this project's fields, items, or 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 this project: add, update, or remove items, post status updates, and create iteration fields.", + Description: "Manage {{.ProjectRef}}: add, update, or remove items, post status updates, and create iteration fields.", }, }, }