diff --git a/Dockerfile b/Dockerfile
index 65d0f9e1ca..a4ea1d03b8 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,4 +1,4 @@
-FROM node:26-alpine@sha256:7c6af15abe4e3de859690e7db171d0d711bf37d27528eddfe625b2fe89e097f8 AS ui-build
+FROM node:26-alpine@sha256:144769ec3f32e8ee36b3cfde91e82bee25d9367b20f31a151f3f7eea3a2a8541 AS ui-build
WORKDIR /app
COPY ui/package*.json ./ui/
RUN cd ui && npm ci
@@ -7,7 +7,7 @@ COPY ui/ ./ui/
RUN mkdir -p ./pkg/github/ui_dist && \
cd ui && npm run build
-FROM golang:1.25.10-alpine@sha256:8d22e29d960bc50cd025d93d5b7c7d220b1ee9aa7a239b3c8f55a57e987e8d45 AS build
+FROM golang:1.25.11-alpine@sha256:cd2fb3559df6e13bc93b7f0734a4eabe1d21e7b64eec211ed90784f00a17a56a AS build
ARG VERSION="dev"
# Set the working directory
diff --git a/README.md b/README.md
index 9082a3b642..dc063f22ce 100644
--- a/README.md
+++ b/README.md
@@ -18,7 +18,7 @@ Built for developers who want to connect their AI tools to GitHub context and ca
## Remote GitHub MCP Server
-[](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2F%22%7D) [](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2F%22%7D&quality=insiders)
+[](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2F%22%7D) [](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2F%22%7D&quality=insiders) [](https://aka.ms/vs/mcp-install?%7B%22name%22%3A%22github%22%2C%22gallery%22%3Atrue%2C%22url%22%3A%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2F%22%7D)
The remote GitHub MCP Server is hosted by GitHub and provides the easiest method for getting up and running. If your MCP host does not support remote MCP servers, don't worry! You can use the [local version of the GitHub MCP Server](https://github.com/github/github-mcp-server?tab=readme-ov-file#local-github-mcp-server) instead.
@@ -86,7 +86,9 @@ Alternatively, to manually configure VS Code, choose the appropriate JSON block
- **[Claude Applications](/docs/installation-guides/install-claude.md)** - Installation guide for Claude Desktop and Claude Code CLI
- **[Codex](/docs/installation-guides/install-codex.md)** - Installation guide for OpenAI Codex
- **[Cursor](/docs/installation-guides/install-cursor.md)** - Installation guide for Cursor IDE
+- **[OpenCode](/docs/installation-guides/install-opencode.md)** - Installation guide for the OpenCode terminal agent
- **[Windsurf](/docs/installation-guides/install-windsurf.md)** - Installation guide for Windsurf IDE
+- **[Zed](/docs/installation-guides/install-zed.md)** - Installation guide for Zed editor
- **[Rovo Dev CLI](/docs/installation-guides/install-rovo-dev-cli.md)** - Installation guide for Rovo Dev CLI
> **Note:** Each MCP host application needs to configure a GitHub App or OAuth App to support remote access via OAuth. Any host application that supports remote MCP servers should support the remote GitHub server with PAT authentication. Configuration details and support levels vary by host. Make sure to refer to the host application's documentation for more info.
@@ -174,7 +176,7 @@ GitHub Enterprise Server does not support remote server hosting. Please refer to
## Local GitHub MCP Server
-[](https://insiders.vscode.dev/redirect/mcp/install?name=github&inputs=%5B%7B%22id%22%3A%22github_token%22%2C%22type%22%3A%22promptString%22%2C%22description%22%3A%22GitHub%20Personal%20Access%20Token%22%2C%22password%22%3Atrue%7D%5D&config=%7B%22command%22%3A%22docker%22%2C%22args%22%3A%5B%22run%22%2C%22-i%22%2C%22--rm%22%2C%22-e%22%2C%22GITHUB_PERSONAL_ACCESS_TOKEN%22%2C%22ghcr.io%2Fgithub%2Fgithub-mcp-server%22%5D%2C%22env%22%3A%7B%22GITHUB_PERSONAL_ACCESS_TOKEN%22%3A%22%24%7Binput%3Agithub_token%7D%22%7D%7D) [](https://insiders.vscode.dev/redirect/mcp/install?name=github&inputs=%5B%7B%22id%22%3A%22github_token%22%2C%22type%22%3A%22promptString%22%2C%22description%22%3A%22GitHub%20Personal%20Access%20Token%22%2C%22password%22%3Atrue%7D%5D&config=%7B%22command%22%3A%22docker%22%2C%22args%22%3A%5B%22run%22%2C%22-i%22%2C%22--rm%22%2C%22-e%22%2C%22GITHUB_PERSONAL_ACCESS_TOKEN%22%2C%22ghcr.io%2Fgithub%2Fgithub-mcp-server%22%5D%2C%22env%22%3A%7B%22GITHUB_PERSONAL_ACCESS_TOKEN%22%3A%22%24%7Binput%3Agithub_token%7D%22%7D%7D&quality=insiders)
+[](https://insiders.vscode.dev/redirect/mcp/install?name=github&inputs=%5B%7B%22id%22%3A%22github_token%22%2C%22type%22%3A%22promptString%22%2C%22description%22%3A%22GitHub%20Personal%20Access%20Token%22%2C%22password%22%3Atrue%7D%5D&config=%7B%22command%22%3A%22docker%22%2C%22args%22%3A%5B%22run%22%2C%22-i%22%2C%22--rm%22%2C%22-e%22%2C%22GITHUB_PERSONAL_ACCESS_TOKEN%22%2C%22ghcr.io%2Fgithub%2Fgithub-mcp-server%22%5D%2C%22env%22%3A%7B%22GITHUB_PERSONAL_ACCESS_TOKEN%22%3A%22%24%7Binput%3Agithub_token%7D%22%7D%7D) [](https://insiders.vscode.dev/redirect/mcp/install?name=github&inputs=%5B%7B%22id%22%3A%22github_token%22%2C%22type%22%3A%22promptString%22%2C%22description%22%3A%22GitHub%20Personal%20Access%20Token%22%2C%22password%22%3Atrue%7D%5D&config=%7B%22command%22%3A%22docker%22%2C%22args%22%3A%5B%22run%22%2C%22-i%22%2C%22--rm%22%2C%22-e%22%2C%22GITHUB_PERSONAL_ACCESS_TOKEN%22%2C%22ghcr.io%2Fgithub%2Fgithub-mcp-server%22%5D%2C%22env%22%3A%7B%22GITHUB_PERSONAL_ACCESS_TOKEN%22%3A%22%24%7Binput%3Agithub_token%7D%22%7D%7D&quality=insiders) [](https://aka.ms/vs/mcp-install?%7B%22name%22%3A%22github%22%2C%22command%22%3A%22docker%22%2C%22args%22%3A%5B%22run%22%2C%22-i%22%2C%22--rm%22%2C%22-e%22%2C%22GITHUB_PERSONAL_ACCESS_TOKEN%22%2C%22ghcr.io%2Fgithub%2Fgithub-mcp-server%22%5D%7D)
### Prerequisites
@@ -356,7 +358,9 @@ For other MCP host applications, please refer to our installation guides:
- **[Claude Code & Claude Desktop](docs/installation-guides/install-claude.md)** - Installation guide for Claude Code and Claude Desktop
- **[Cursor](docs/installation-guides/install-cursor.md)** - Installation guide for Cursor IDE
- **[Google Gemini CLI](docs/installation-guides/install-gemini-cli.md)** - Installation guide for Google Gemini CLI
+- **[OpenCode](docs/installation-guides/install-opencode.md)** - Installation guide for the OpenCode terminal agent
- **[Windsurf](docs/installation-guides/install-windsurf.md)** - Installation guide for Windsurf IDE
+- **[Zed](docs/installation-guides/install-zed.md)** - Installation guide for Zed editor
For a complete overview of all installation options, see our **[Installation Guides Index](docs/installation-guides)**.
@@ -713,8 +717,8 @@ The following sets of tools are available:
- **list_dependabot_alerts** - List dependabot alerts
- **Required OAuth Scopes**: `security_events`
- **Accepted OAuth Scopes**: `repo`, `security_events`
+ - `after`: Cursor for pagination. Use the cursor from the previous response. (string, optional)
- `owner`: The owner of the repository. (string, required)
- - `page`: Page number for pagination (min 1) (number, optional)
- `perPage`: Results per page for pagination (min 1, max 100) (number, optional)
- `repo`: The name of the repository. (string, required)
- `severity`: Filter dependabot alerts by severity (string, optional)
@@ -751,7 +755,7 @@ The following sets of tools are available:
- **get_discussion_comments** - Get discussion comments
- **Required OAuth Scopes**: `repo`
- - `after`: Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs. (string, optional)
+ - `after`: Cursor for pagination. Use the cursor from the previous response. (string, optional)
- `discussionNumber`: Discussion Number (number, required)
- `includeReplies`: When true, each top-level comment will include its replies nested within it (up to 100 replies per comment, which is the GitHub API maximum). Defaults to false. (boolean, optional)
- `owner`: Repository owner (string, required)
@@ -765,7 +769,7 @@ The following sets of tools are available:
- **list_discussions** - List discussions
- **Required OAuth Scopes**: `repo`
- - `after`: Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs. (string, optional)
+ - `after`: Cursor for pagination. Use the cursor from the previous response. (string, optional)
- `category`: Optional filter by discussion category ID. If provided, only discussions with this category are listed. (string, optional)
- `direction`: Order direction. (string, optional)
- `orderBy`: Order discussions by field. If provided, the 'direction' also needs to be provided. (string, optional)
@@ -822,7 +826,7 @@ The following sets of tools are available:
Issues
-- **add_issue_comment** - Add comment to issue
+- **add_issue_comment** - Add comment to issue or pull request
- **Required OAuth Scopes**: `repo`
- `body`: Comment content (string, required)
- `issue_number`: Issue number to comment on (number, required)
@@ -850,7 +854,7 @@ The following sets of tools are available:
- `perPage`: Results per page for pagination (min 1, max 100) (number, optional)
- `repo`: The name of the repository (string, required)
-- **issue_write** - Create or update issue
+- **issue_write** - Create or update issue/pull request
- **Required OAuth Scopes**: `repo`
- `assignees`: Usernames to assign to this issue (string[], optional)
- `body`: Issue body content (string, optional)
@@ -877,7 +881,7 @@ The following sets of tools are available:
- **list_issues** - List issues
- **Required OAuth Scopes**: `repo`
- - `after`: Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs. (string, optional)
+ - `after`: Cursor for pagination. Use the cursor from the previous response. (string, optional)
- `direction`: Order direction. If provided, the 'orderBy' also needs to be provided. (string, optional)
- `labels`: Filter by labels (string[], optional)
- `orderBy`: Order issues by field. If provided, the 'direction' also needs to be provided. (string, optional)
@@ -1028,22 +1032,26 @@ The following sets of tools are available:
- `project_number`: The project's number. Required for 'list_project_fields', 'list_project_items', and 'list_project_status_updates' methods. (number, optional)
- `query`: 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. (string, optional)
-- **projects_write** - Modify GitHub Project items
+- **projects_write** - Manage GitHub Projects
- **Required OAuth Scopes**: `project`
- `body`: The body of the status update (markdown). Used for 'create_project_status_update' method. (string, optional)
+ - `field_name`: The name of the iteration field (e.g. 'Sprint'). Required for 'create_iteration_field' method. (string, optional)
- `issue_number`: The issue number (use when item_type is 'issue' for 'add_project_item' method). Provide either issue_number or pull_request_number. (number, optional)
- `item_id`: The project item ID. Required for 'update_project_item' and 'delete_project_item' methods. (number, optional)
- `item_owner`: The owner (user or organization) of the repository containing the issue or pull request. Required for 'add_project_item' method. (string, optional)
- `item_repo`: The name of the repository containing the issue or pull request. Required for 'add_project_item' method. (string, optional)
- `item_type`: The item's type, either issue or pull_request. Required for 'add_project_item' method. (string, optional)
+ - `iteration_duration`: Duration in days for iterations of the field (e.g. 7 for weekly, 14 for bi-weekly). Required for 'create_iteration_field' method. (number, optional)
+ - `iterations`: 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. (object[], optional)
- `method`: The method to execute (string, required)
- `owner`: The project owner (user or organization login). The name is not case sensitive. (string, required)
- - `owner_type`: Owner type (user or org). If not provided, will be automatically detected. (string, optional)
- - `project_number`: The project's number. (number, required)
+ - `owner_type`: Owner type (user or org). Required for 'create_project' method. If not provided for other methods, will be automatically detected. (string, optional)
+ - `project_number`: The project's number. Required for all methods except 'create_project'. (number, optional)
- `pull_request_number`: The pull request number (use when item_type is 'pull_request' for 'add_project_item' method). Provide either issue_number or pull_request_number. (number, optional)
- - `start_date`: The start date of the status update in YYYY-MM-DD format. Used for 'create_project_status_update' method. (string, optional)
+ - `start_date`: Start date in YYYY-MM-DD format. Used for 'create_project_status_update' and 'create_iteration_field' methods. (string, optional)
- `status`: The status of the project. Used for 'create_project_status_update' method. (string, optional)
- `target_date`: The target date of the status update in YYYY-MM-DD format. Used for 'create_project_status_update' method. (string, optional)
+ - `title`: The project title. Required for 'create_project' method. (string, optional)
- `updated_field`: 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. (object, optional)
@@ -1114,10 +1122,11 @@ The following sets of tools are available:
2. get_diff - Get the diff of a pull request.
3. get_status - Get combined commit status of a head commit in a pull request.
4. get_files - Get the list of files changed in a pull request. Use with pagination parameters to control the number of results returned.
- 5. 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.
- 6. 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.
- 7. 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.
- 8. 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.
+ 5. get_commits - Get the list of commits on a pull request. Use with pagination parameters to control the number of results returned.
+ 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.
+ 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.
+ 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.
+ 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.
(string, required)
- `owner`: Repository owner (string, required)
- `page`: Page number for pagination (min 1) (number, optional)
@@ -1155,7 +1164,7 @@ The following sets of tools are available:
- `owner`: Repository owner (string, required)
- `pullNumber`: Pull request number to update (number, required)
- `repo`: Repository name (string, required)
- - `reviewers`: GitHub usernames to request reviews from (string[], optional)
+ - `reviewers`: GitHub usernames or ORG/team-slug team reviewers to request reviews from (string[], optional)
- `state`: New state (string, optional)
- `title`: New title (string, optional)
@@ -1213,7 +1222,7 @@ The following sets of tools are available:
- **get_commit** - Get commit details
- **Required OAuth Scopes**: `repo`
- - `include_diff`: Whether to include file diffs and stats in the response. Default is true. (boolean, optional)
+ - `detail`: 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. (string, optional)
- `owner`: Repository owner (string, required)
- `page`: Page number for pagination (min 1) (number, optional)
- `perPage`: Results per page for pagination (min 1, max 100) (number, optional)
diff --git a/cmd/github-mcp-server/generate_docs.go b/cmd/github-mcp-server/generate_docs.go
index efa8f7c393..78ed8361a8 100644
--- a/cmd/github-mcp-server/generate_docs.go
+++ b/cmd/github-mcp-server/generate_docs.go
@@ -365,9 +365,11 @@ func generateRemoteToolsetsDoc() string {
buf.WriteString("| Name | Description | API URL | 1-Click Install (VS Code) | Read-only Link | 1-Click Read-only Install (VS Code) |\n")
buf.WriteString("| ---- | ----------- | ------- | ------------------------- | -------------- | ----------------------------------- |\n")
- // Add "all" toolset first (special case)
- allIcon := octiconImg("apps", "../")
- fmt.Fprintf(&buf, "| %s
`all` | All available GitHub MCP tools | https://api.githubcopilot.com/mcp/ | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%%7B%%22type%%22%%3A%%20%%22http%%22%%2C%%22url%%22%%3A%%20%%22https%%3A%%2F%%2Fapi.githubcopilot.com%%2Fmcp%%2F%%22%%7D) | [read-only](https://api.githubcopilot.com/mcp/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%%7B%%22type%%22%%3A%%20%%22http%%22%%2C%%22url%%22%%3A%%20%%22https%%3A%%2F%%2Fapi.githubcopilot.com%%2Fmcp%%2Freadonly%%22%%7D) |\n", allIcon)
+ // Add "default" and "all" meta toolsets first (special cases). The base
+ // URL serves the default toolset; /x/all enables every toolset at once.
+ metaIcon := octiconImg("apps", "../")
+ fmt.Fprintf(&buf, "| %s
`default` | Default toolset | https://api.githubcopilot.com/mcp/ | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%%7B%%22type%%22%%3A%%20%%22http%%22%%2C%%22url%%22%%3A%%20%%22https%%3A%%2F%%2Fapi.githubcopilot.com%%2Fmcp%%2F%%22%%7D) | [read-only](https://api.githubcopilot.com/mcp/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%%7B%%22type%%22%%3A%%20%%22http%%22%%2C%%22url%%22%%3A%%20%%22https%%3A%%2F%%2Fapi.githubcopilot.com%%2Fmcp%%2Freadonly%%22%%7D) |\n", metaIcon)
+ fmt.Fprintf(&buf, "| %s
`all` | All available GitHub MCP tools | https://api.githubcopilot.com/mcp/x/all | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-all&config=%%7B%%22type%%22%%3A%%20%%22http%%22%%2C%%22url%%22%%3A%%20%%22https%%3A%%2F%%2Fapi.githubcopilot.com%%2Fmcp%%2Fx%%2Fall%%22%%7D) | [read-only](https://api.githubcopilot.com/mcp/x/all/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-all&config=%%7B%%22type%%22%%3A%%20%%22http%%22%%2C%%22url%%22%%3A%%20%%22https%%3A%%2F%%2Fapi.githubcopilot.com%%2Fmcp%%2Fx%%2Fall%%2Freadonly%%22%%7D) |\n", metaIcon)
// AvailableToolsets() returns toolsets that have tools, sorted by ID
// Exclude context (handled separately)
diff --git a/cmd/mcpcurl/main.go b/cmd/mcpcurl/main.go
index f35e6926c3..f40e842530 100644
--- a/cmd/mcpcurl/main.go
+++ b/cmd/mcpcurl/main.go
@@ -1,7 +1,7 @@
package main
import (
- "bytes"
+ "bufio"
"crypto/rand"
"encoding/json"
"fmt"
@@ -376,8 +376,8 @@ func buildJSONRPCRequest(method, toolName string, arguments map[string]any) (str
return string(jsonData), nil
}
-// executeServerCommand runs the specified command, sends the JSON request to stdin,
-// and returns the response from stdout
+// executeServerCommand runs the specified command, performs the MCP initialization
+// handshake, sends the JSON request to stdin, and returns the response from stdout.
func executeServerCommand(cmdStr, jsonRequest string) (string, error) {
// Split the command string into command and arguments
cmdParts := strings.Fields(cmdStr)
@@ -393,9 +393,14 @@ func executeServerCommand(cmdStr, jsonRequest string) (string, error) {
return "", fmt.Errorf("failed to create stdin pipe: %w", err)
}
- // Setup stdout and stderr pipes
- var stdout, stderr bytes.Buffer
- cmd.Stdout = &stdout
+ // Setup stdout pipe for line-by-line reading
+ stdoutPipe, err := cmd.StdoutPipe()
+ if err != nil {
+ return "", fmt.Errorf("failed to create stdout pipe: %w", err)
+ }
+
+ // Stderr still uses a buffer
+ var stderr strings.Builder
cmd.Stderr = &stderr
// Start the command
@@ -403,18 +408,104 @@ func executeServerCommand(cmdStr, jsonRequest string) (string, error) {
return "", fmt.Errorf("failed to start command: %w", err)
}
- // Write the JSON request to stdin
+ // Ensure the child process is cleaned up on every return path.
+ // stdin must be closed before Wait so the server sees EOF and exits;
+ // its non-zero exit status on EOF is expected, so we ignore the error.
+ defer func() {
+ _ = stdin.Close()
+ _ = cmd.Wait()
+ }()
+
+ // Use a scanner with a large buffer for reading JSON-RPC responses
+ scanner := bufio.NewScanner(stdoutPipe)
+ scanner.Buffer(make([]byte, 0, 1024*1024), 1024*1024) // 1MB max line size
+
+ // Step 1: Send MCP initialize request
+ initReq, err := buildInitializeRequest()
+ if err != nil {
+ return "", fmt.Errorf("failed to build initialize request: %w", err)
+ }
+ if _, err := io.WriteString(stdin, initReq+"\n"); err != nil {
+ return "", fmt.Errorf("failed to write initialize request: %w", err)
+ }
+
+ // Step 2: Read initialize response (skip any server notifications)
+ if _, err := readJSONRPCResponse(scanner); err != nil {
+ return "", fmt.Errorf("failed to read initialize response: %w, stderr: %s", err, stderr.String())
+ }
+
+ // Step 3: Send initialized notification
+ if _, err := io.WriteString(stdin, buildInitializedNotification()+"\n"); err != nil {
+ return "", fmt.Errorf("failed to write initialized notification: %w", err)
+ }
+
+ // Step 4: Send the actual request
if _, err := io.WriteString(stdin, jsonRequest+"\n"); err != nil {
- return "", fmt.Errorf("failed to write to stdin: %w", err)
+ return "", fmt.Errorf("failed to write request: %w", err)
}
- _ = stdin.Close()
- // Wait for the command to complete
- if err := cmd.Wait(); err != nil {
- return "", fmt.Errorf("command failed: %w, stderr: %s", err, stderr.String())
+ // Step 5: Read the actual response (skip any server notifications)
+ response, err := readJSONRPCResponse(scanner)
+ if err != nil {
+ return "", fmt.Errorf("failed to read response: %w, stderr: %s", err, stderr.String())
}
- return stdout.String(), nil
+ return response, nil
+}
+
+// buildInitializeRequest creates the MCP initialize handshake request.
+func buildInitializeRequest() (string, error) {
+ id, err := rand.Int(rand.Reader, big.NewInt(10000))
+ if err != nil {
+ return "", fmt.Errorf("failed to generate random ID: %w", err)
+ }
+ msg := map[string]any{
+ "jsonrpc": "2.0",
+ "id": int(id.Int64()),
+ "method": "initialize",
+ "params": map[string]any{
+ "protocolVersion": "2024-11-05",
+ "capabilities": map[string]any{},
+ "clientInfo": map[string]any{
+ "name": "mcpcurl",
+ "version": "0.1.0",
+ },
+ },
+ }
+ data, err := json.Marshal(msg)
+ if err != nil {
+ return "", fmt.Errorf("failed to marshal initialize request: %w", err)
+ }
+ return string(data), nil
+}
+
+// buildInitializedNotification creates the MCP initialized notification.
+func buildInitializedNotification() string {
+ return `{"jsonrpc":"2.0","method":"notifications/initialized"}`
+}
+
+// readJSONRPCResponse reads lines from the scanner, skipping server-initiated
+// notifications (messages without an "id" field), and returns the first response.
+func readJSONRPCResponse(scanner *bufio.Scanner) (string, error) {
+ for scanner.Scan() {
+ line := scanner.Text()
+ // JSON-RPC responses have an "id" field; notifications do not.
+ var msg map[string]json.RawMessage
+ if err := json.Unmarshal([]byte(line), &msg); err != nil {
+ return "", fmt.Errorf("failed to parse JSON-RPC message: %w", err)
+ }
+ if _, hasID := msg["id"]; hasID {
+ if errField, hasErr := msg["error"]; hasErr {
+ return "", fmt.Errorf("server returned error: %s", string(errField))
+ }
+ return line, nil
+ }
+ // No "id" — this is a notification, skip it
+ }
+ if err := scanner.Err(); err != nil {
+ return "", err
+ }
+ return "", fmt.Errorf("unexpected end of output")
}
func printResponse(response string, prettyPrint bool) error {
diff --git a/cmd/mcpcurl/main_test.go b/cmd/mcpcurl/main_test.go
new file mode 100644
index 0000000000..3d0b00d2a5
--- /dev/null
+++ b/cmd/mcpcurl/main_test.go
@@ -0,0 +1,178 @@
+package main
+
+import (
+ "bufio"
+ "encoding/json"
+ "strings"
+ "testing"
+)
+
+func TestReadJSONRPCResponse_DirectResponse(t *testing.T) {
+ t.Parallel()
+ input := `{"jsonrpc":"2.0","id":1,"result":{"tools":[]}}` + "\n"
+ scanner := bufio.NewScanner(strings.NewReader(input))
+
+ got, err := readJSONRPCResponse(scanner)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if got != `{"jsonrpc":"2.0","id":1,"result":{"tools":[]}}` {
+ t.Fatalf("unexpected response: %s", got)
+ }
+}
+
+func TestReadJSONRPCResponse_SkipsNotifications(t *testing.T) {
+ t.Parallel()
+ input := strings.Join([]string{
+ `{"jsonrpc":"2.0","method":"notifications/resources/list_changed","params":{}}`,
+ `{"jsonrpc":"2.0","method":"notifications/tools/list_changed"}`,
+ `{"jsonrpc":"2.0","id":42,"result":{"content":[{"type":"text","text":"hello"}]}}`,
+ }, "\n") + "\n"
+ scanner := bufio.NewScanner(strings.NewReader(input))
+
+ got, err := readJSONRPCResponse(scanner)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ var msg map[string]json.RawMessage
+ if err := json.Unmarshal([]byte(got), &msg); err != nil {
+ t.Fatalf("response is not valid JSON: %v", err)
+ }
+ // Verify we got the response with id:42, not a notification
+ var id int
+ if err := json.Unmarshal(msg["id"], &id); err != nil {
+ t.Fatalf("failed to parse id: %v", err)
+ }
+ if id != 42 {
+ t.Fatalf("expected id 42, got %d", id)
+ }
+}
+
+func TestReadJSONRPCResponse_NoResponse(t *testing.T) {
+ t.Parallel()
+ // Only notifications, no response
+ input := `{"jsonrpc":"2.0","method":"notifications/resources/list_changed","params":{}}` + "\n"
+ scanner := bufio.NewScanner(strings.NewReader(input))
+
+ _, err := readJSONRPCResponse(scanner)
+ if err == nil {
+ t.Fatal("expected error for missing response, got nil")
+ }
+ if !strings.Contains(err.Error(), "unexpected end of output") {
+ t.Fatalf("expected 'unexpected end of output' error, got: %v", err)
+ }
+}
+
+func TestReadJSONRPCResponse_EmptyInput(t *testing.T) {
+ t.Parallel()
+ scanner := bufio.NewScanner(strings.NewReader(""))
+
+ _, err := readJSONRPCResponse(scanner)
+ if err == nil {
+ t.Fatal("expected error for empty input, got nil")
+ }
+}
+
+func TestReadJSONRPCResponse_InvalidJSON(t *testing.T) {
+ t.Parallel()
+ input := "not valid json\n"
+ scanner := bufio.NewScanner(strings.NewReader(input))
+
+ _, err := readJSONRPCResponse(scanner)
+ if err == nil {
+ t.Fatal("expected error for invalid JSON, got nil")
+ }
+ if !strings.Contains(err.Error(), "failed to parse JSON-RPC message") {
+ t.Fatalf("expected parse error, got: %v", err)
+ }
+}
+
+func TestReadJSONRPCResponse_ServerError(t *testing.T) {
+ t.Parallel()
+ input := `{"jsonrpc":"2.0","id":1,"error":{"code":-32601,"message":"method not found"}}` + "\n"
+ scanner := bufio.NewScanner(strings.NewReader(input))
+
+ _, err := readJSONRPCResponse(scanner)
+ if err == nil {
+ t.Fatal("expected error for server error response, got nil")
+ }
+ if !strings.Contains(err.Error(), "server returned error") {
+ t.Fatalf("expected 'server returned error', got: %v", err)
+ }
+ if !strings.Contains(err.Error(), "method not found") {
+ t.Fatalf("expected error to contain server message, got: %v", err)
+ }
+}
+
+func TestBuildInitializeRequest(t *testing.T) {
+ t.Parallel()
+ got, err := buildInitializeRequest()
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ var msg map[string]json.RawMessage
+ if err := json.Unmarshal([]byte(got), &msg); err != nil {
+ t.Fatalf("result is not valid JSON: %v", err)
+ }
+
+ // Verify required fields
+ for _, field := range []string{"jsonrpc", "id", "method", "params"} {
+ if _, ok := msg[field]; !ok {
+ t.Errorf("missing required field %q", field)
+ }
+ }
+
+ // Verify method
+ var method string
+ if err := json.Unmarshal(msg["method"], &method); err != nil {
+ t.Fatalf("failed to parse method: %v", err)
+ }
+ if method != "initialize" {
+ t.Errorf("expected method 'initialize', got %q", method)
+ }
+
+ // Verify params contain protocolVersion and clientInfo
+ var params map[string]json.RawMessage
+ if err := json.Unmarshal(msg["params"], ¶ms); err != nil {
+ t.Fatalf("failed to parse params: %v", err)
+ }
+ for _, field := range []string{"protocolVersion", "capabilities", "clientInfo"} {
+ if _, ok := params[field]; !ok {
+ t.Errorf("missing params field %q", field)
+ }
+ }
+
+ var version string
+ if err := json.Unmarshal(params["protocolVersion"], &version); err != nil {
+ t.Fatalf("failed to parse protocolVersion: %v", err)
+ }
+ if version != "2024-11-05" {
+ t.Errorf("expected protocolVersion '2024-11-05', got %q", version)
+ }
+}
+
+func TestBuildInitializedNotification(t *testing.T) {
+ t.Parallel()
+ got := buildInitializedNotification()
+
+ var msg map[string]json.RawMessage
+ if err := json.Unmarshal([]byte(got), &msg); err != nil {
+ t.Fatalf("result is not valid JSON: %v", err)
+ }
+
+ // Must have jsonrpc and method
+ var method string
+ if err := json.Unmarshal(msg["method"], &method); err != nil {
+ t.Fatalf("failed to parse method: %v", err)
+ }
+ if method != "notifications/initialized" {
+ t.Errorf("expected method 'notifications/initialized', got %q", method)
+ }
+
+ // Must NOT have an id (it's a notification)
+ if _, hasID := msg["id"]; hasID {
+ t.Error("notification should not have an 'id' field")
+ }
+}
diff --git a/docs/feature-flags.md b/docs/feature-flags.md
index 0b75a61bac..cb02463a10 100644
--- a/docs/feature-flags.md
+++ b/docs/feature-flags.md
@@ -50,7 +50,7 @@ runtime behavior (such as output formatting) won't appear here.
- **MCP App UI**: `ui://github-mcp-server/get-me`
- No parameters required
-- **issue_write** - Create or update issue
+- **issue_write** - Create or update issue/pull request
- **Required OAuth Scopes**: `repo`
- **MCP App UI**: `ui://github-mcp-server/issue-write`
- `assignees`: Usernames to assign to this issue (string[], optional)
@@ -73,7 +73,7 @@ runtime behavior (such as output formatting) won't appear here.
### `remote_mcp_issue_fields`
-- **issue_write** - Create or update issue
+- **issue_write** - Create or update issue/pull request
- **Required OAuth Scopes**: `repo`
- `assignees`: Usernames to assign to this issue (string[], optional)
- `body`: Issue body content (string, optional)
@@ -102,7 +102,7 @@ runtime behavior (such as output formatting) won't appear here.
- **list_issues** - List issues
- **Required OAuth Scopes**: `repo`
- - `after`: Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs. (string, optional)
+ - `after`: Cursor for pagination. Use the cursor from the previous response. (string, optional)
- `direction`: Order direction. If provided, the 'orderBy' also needs to be provided. (string, optional)
- `field_filters`: Filter by custom issue field values. Each entry takes a field_name and a value; the server looks up the field and coerces the value to its type (single-select option name, text, number, or YYYY-MM-DD date). (object[], optional)
- `labels`: Filter by labels (string[], optional)
@@ -198,6 +198,7 @@ runtime behavior (such as output formatting) won't appear here.
- **update_issue_type** - Update Issue Type
- **Required OAuth Scopes**: `repo`
+ - `confidence`: How confident you are in this choice. Use 'high' for clear signal or explicit user request, 'medium' for reasonable inference with some ambiguity, 'low' for best guess with limited signal. (string, optional)
- `is_suggestion`: If true, this issue type change is sent to the API as a suggestion (suggest:true) rather than an applied value. Whether the type is applied or recorded as a proposal is determined by the API. (boolean, optional)
- `issue_number`: The issue number to update (number, required)
- `issue_type`: The issue type to set (string, required)
@@ -240,7 +241,7 @@ runtime behavior (such as output formatting) won't appear here.
- `owner`: Repository owner (username or organization) (string, required)
- `pullNumber`: The pull request number (number, required)
- `repo`: Repository name (string, required)
- - `reviewers`: GitHub usernames to request reviews from (string[], required)
+ - `reviewers`: GitHub usernames or ORG/team-slug team reviewers to request reviews from (string[], required)
- **resolve_review_thread** - Resolve Review Thread
- **Required OAuth Scopes**: `repo`
@@ -286,4 +287,17 @@ runtime behavior (such as output formatting) won't appear here.
- `repo`: Repository name (string, required)
- `title`: The new title for the pull request (string, required)
+### `file_blame`
+
+- **get_file_blame** - Get file blame information
+ - **Required OAuth Scopes**: `repo`
+ - `after`: Cursor for pagination. Use the cursor from the previous response. (string, optional)
+ - `end_line`: Optional 1-based ending line of the window of interest. Must be >= start_line when both are provided. (number, optional)
+ - `owner`: Repository owner (username or organization) (string, required)
+ - `path`: Path to the file in the repository, relative to the repository root (string, required)
+ - `perPage`: Results per page for pagination (min 1, max 100) (number, optional)
+ - `ref`: Git reference (branch, tag, or commit SHA). Defaults to the repository's default branch (HEAD). (string, optional)
+ - `repo`: Repository name (string, required)
+ - `start_line`: Optional 1-based starting line of the window of interest. Only ranges overlapping [start_line, end_line] are returned, clamped to the window. (number, optional)
+
diff --git a/docs/insiders-features.md b/docs/insiders-features.md
index 881030f020..2277f0c8e2 100644
--- a/docs/insiders-features.md
+++ b/docs/insiders-features.md
@@ -44,7 +44,7 @@ The list below is generated from the Go source. It covers tool **inventory and s
- **MCP App UI**: `ui://github-mcp-server/get-me`
- No parameters required
-- **issue_write** - Create or update issue
+- **issue_write** - Create or update issue/pull request
- **Required OAuth Scopes**: `repo`
- **MCP App UI**: `ui://github-mcp-server/issue-write`
- `assignees`: Usernames to assign to this issue (string[], optional)
@@ -67,7 +67,7 @@ The list below is generated from the Go source. It covers tool **inventory and s
### `remote_mcp_issue_fields`
-- **issue_write** - Create or update issue
+- **issue_write** - Create or update issue/pull request
- **Required OAuth Scopes**: `repo`
- `assignees`: Usernames to assign to this issue (string[], optional)
- `body`: Issue body content (string, optional)
@@ -96,7 +96,7 @@ The list below is generated from the Go source. It covers tool **inventory and s
- **list_issues** - List issues
- **Required OAuth Scopes**: `repo`
- - `after`: Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs. (string, optional)
+ - `after`: Cursor for pagination. Use the cursor from the previous response. (string, optional)
- `direction`: Order direction. If provided, the 'orderBy' also needs to be provided. (string, optional)
- `field_filters`: Filter by custom issue field values. Each entry takes a field_name and a value; the server looks up the field and coerces the value to its type (single-select option name, text, number, or YYYY-MM-DD date). (object[], optional)
- `labels`: Filter by labels (string[], optional)
@@ -107,6 +107,19 @@ The list below is generated from the Go source. It covers tool **inventory and s
- `since`: Filter by date (ISO 8601 timestamp) (string, optional)
- `state`: Filter by state, by default both open and closed issues are returned when not provided (string, optional)
+### `file_blame`
+
+- **get_file_blame** - Get file blame information
+ - **Required OAuth Scopes**: `repo`
+ - `after`: Cursor for pagination. Use the cursor from the previous response. (string, optional)
+ - `end_line`: Optional 1-based ending line of the window of interest. Must be >= start_line when both are provided. (number, optional)
+ - `owner`: Repository owner (username or organization) (string, required)
+ - `path`: Path to the file in the repository, relative to the repository root (string, required)
+ - `perPage`: Results per page for pagination (min 1, max 100) (number, optional)
+ - `ref`: Git reference (branch, tag, or commit SHA). Defaults to the repository's default branch (HEAD). (string, optional)
+ - `repo`: Repository name (string, required)
+ - `start_line`: Optional 1-based starting line of the window of interest. Only ranges overlapping [start_line, end_line] are returned, clamped to the window. (number, optional)
+
---
diff --git a/docs/installation-guides/README.md b/docs/installation-guides/README.md
index 0c0f7840ef..46581aa77e 100644
--- a/docs/installation-guides/README.md
+++ b/docs/installation-guides/README.md
@@ -6,14 +6,16 @@ This directory contains detailed installation instructions for the GitHub MCP Se
- **[Copilot CLI](install-copilot-cli.md)** - Installation guide for GitHub Copilot CLI
- **[GitHub Copilot in other IDEs](install-other-copilot-ides.md)** - Installation for JetBrains, Visual Studio, Eclipse, and Xcode with GitHub Copilot
- **[Antigravity](install-antigravity.md)** - Installation for Google Antigravity IDE
-- **[Claude Applications](install-claude.md)** - Installation guide for Claude Web, Claude Desktop and Claude Code CLI
+- **[Claude Applications](install-claude.md)** - Installation guide for Claude Desktop and Claude Code CLI
- **[Cline](install-cline.md)** - Installation guide for Cline
- **[Cursor](install-cursor.md)** - Installation guide for Cursor IDE
- **[Google Gemini CLI](install-gemini-cli.md)** - Installation guide for Google Gemini CLI
- **[OpenAI Codex](install-codex.md)** - Installation guide for OpenAI Codex
+- **[OpenCode](install-opencode.md)** - Installation guide for the OpenCode terminal agent
- **[Roo Code](install-roo-code.md)** - Installation guide for Roo Code
- **[Windsurf](install-windsurf.md)** - Installation guide for Windsurf IDE
- **[Xcode (Codex & Claude Agent)](install-xcode.md)** - Installation guide for Codex and Claude Agent within Xcode
+- **[Zed](install-zed.md)** - Installation guide for Zed editor
## Support by Host Application
@@ -29,8 +31,10 @@ This directory contains detailed installation instructions for the GitHub MCP Se
| Cline | ✅ | ✅ PAT + ❌ No OAuth | Docker or Go build, GitHub PAT | Easy |
| Cursor | ✅ | ✅ PAT + ❌ No OAuth | Docker or Go build, GitHub PAT | Easy |
| Google Gemini CLI | ✅ | ✅ PAT + ❌ No OAuth | Docker or Go build, GitHub PAT | Easy |
+| OpenCode | ✅ | ✅ PAT + ❌ No OAuth | Docker or Go build, GitHub PAT | Easy |
| Roo Code | ✅ | ✅ PAT + ❌ No OAuth | Docker or Go build, GitHub PAT | Easy |
| Windsurf | ✅ | ✅ PAT + ❌ No OAuth | Docker or Go build, GitHub PAT | Easy |
+| Zed | ✅ | ✅ PAT + ❌ No OAuth | Docker or Go build, GitHub PAT | Easy |
| Copilot in Xcode | ✅ | ✅ Full (OAuth + PAT) | Local: Docker or Go build, GitHub PAT
Remote: Copilot for Xcode 0.41.0+ | Easy |
| Copilot in Eclipse | ✅ | ✅ Full (OAuth + PAT) | Local: Docker or Go build, GitHub PAT
Remote: Eclipse Plug-in for Copilot 0.10.0+ | Easy |
| Xcode (Codex) | ✅ | ✅ PAT + ❌ No OAuth | Local: Docker (full path required), GitHub PAT
Remote: GitHub PAT via `GITHUB_PAT_TOKEN` env var (`bearer_token_env_var`) | Easy |
diff --git a/docs/installation-guides/install-claude.md b/docs/installation-guides/install-claude.md
index 67003fb69a..d66b34776b 100644
--- a/docs/installation-guides/install-claude.md
+++ b/docs/installation-guides/install-claude.md
@@ -37,9 +37,17 @@ echo -e ".env\n.mcp.json" >> .gitignore
claude mcp add-json github '{"type":"http","url":"https://api.githubcopilot.com/mcp","headers":{"Authorization":"Bearer YOUR_GITHUB_PAT"}}'
```
-With an environment variable:
+With an environment variable (Linux/macOS):
```bash
-claude mcp add-json github '{"type":"http","url":"https://api.githubcopilot.com/mcp","headers":{"Authorization":"Bearer '"$(grep GITHUB_PAT .env | cut -d '=' -f2)"'"}}'
+export GITHUB_PAT="$(grep '^GITHUB_PAT=' .env | cut -d '=' -f2-)"
+claude mcp add-json github '{"type":"http","url":"https://api.githubcopilot.com/mcp","headers":{"Authorization":"Bearer '"$GITHUB_PAT"'"}}'
+```
+
+With an environment variable (Windows PowerShell):
+```powershell
+$githubPatLine = Get-Content .env | Select-String "^\s*GITHUB_PAT\s*=" | Select-Object -First 1
+$env:GITHUB_PAT = ($githubPatLine.Line -split "=", 2)[1].Trim().Trim('"').Trim("'")
+claude mcp add-json github "{`"type`":`"http`",`"url`":`"https://api.githubcopilot.com/mcp`",`"headers`":{`"Authorization`":`"Bearer $env:GITHUB_PAT`"}}"
```
> **About the `--scope` flag** (optional): Use this to specify where the configuration is stored:
diff --git a/docs/installation-guides/install-opencode.md b/docs/installation-guides/install-opencode.md
new file mode 100644
index 0000000000..10e0e2db2a
--- /dev/null
+++ b/docs/installation-guides/install-opencode.md
@@ -0,0 +1,154 @@
+# Install GitHub MCP Server in OpenCode
+
+[OpenCode](https://opencode.ai) is a terminal-based AI coding agent that exposes MCP servers under the `mcp` key in `opencode.json` (or `opencode.jsonc`). For general setup information (prerequisites, Docker installation, security best practices), see the [Installation Guides README](./README.md).
+
+## Prerequisites
+
+1. OpenCode installed (`brew install sst/tap/opencode` or see [OpenCode install docs](https://opencode.ai/docs/))
+2. [GitHub Personal Access Token](https://github.com/settings/personal-access-tokens/new) with appropriate scopes
+3. For local installation: [Docker](https://www.docker.com/) installed and running
+
+> [!IMPORTANT]
+> The OpenCode docs note that the GitHub MCP server can add a lot of tokens to your context. Consider limiting toolsets — for example, by setting `X-MCP-Toolsets` on the remote server or `--toolsets` on the local server — to keep prompts within your model's context window. See the [Server Configuration Guide](../server-configuration.md) and the [main README's toolsets section](../../README.md#available-toolsets).
+
+## Remote Server (Recommended)
+
+Uses GitHub's hosted server at `https://api.githubcopilot.com/mcp/`. Edit your [OpenCode config](https://opencode.ai/docs/config/) (typically `~/.config/opencode/opencode.json`, or `opencode.json` in your project root) and add the following under `mcp`:
+
+```json
+{
+ "$schema": "https://opencode.ai/config.json",
+ "mcp": {
+ "github": {
+ "type": "remote",
+ "url": "https://api.githubcopilot.com/mcp/",
+ "enabled": true,
+ "oauth": false,
+ "headers": {
+ "Authorization": "Bearer YOUR_GITHUB_PAT"
+ }
+ }
+ }
+}
+```
+
+Replace `YOUR_GITHUB_PAT` with your [GitHub Personal Access Token](https://github.com/settings/tokens). The `oauth: false` setting disables OpenCode's automatic OAuth discovery and tells it to use the PAT in `Authorization` instead — without this, OpenCode may try the OAuth flow first.
+
+### Using an environment variable for the PAT
+
+OpenCode supports environment-variable interpolation in config values via `{env:VAR_NAME}`. To avoid putting your PAT directly in `opencode.json`:
+
+```json
+{
+ "$schema": "https://opencode.ai/config.json",
+ "mcp": {
+ "github": {
+ "type": "remote",
+ "url": "https://api.githubcopilot.com/mcp/",
+ "enabled": true,
+ "oauth": false,
+ "headers": {
+ "Authorization": "Bearer {env:GITHUB_PERSONAL_ACCESS_TOKEN}"
+ }
+ }
+ }
+}
+```
+
+Set `GITHUB_PERSONAL_ACCESS_TOKEN` in your shell environment before starting OpenCode.
+
+## Local Server (Docker)
+
+The local GitHub MCP server runs via Docker and requires Docker Desktop (or another Docker runtime) to be installed and running.
+
+```json
+{
+ "$schema": "https://opencode.ai/config.json",
+ "mcp": {
+ "github": {
+ "type": "local",
+ "command": [
+ "docker", "run", "-i", "--rm",
+ "-e", "GITHUB_PERSONAL_ACCESS_TOKEN",
+ "ghcr.io/github/github-mcp-server"
+ ],
+ "enabled": true,
+ "environment": {
+ "GITHUB_PERSONAL_ACCESS_TOKEN": "YOUR_GITHUB_PAT"
+ }
+ }
+ }
+}
+```
+
+> [!IMPORTANT]
+> OpenCode expects `command` as a **single array** combining the executable and its arguments (e.g. `["docker", "run", "-i", ...]`), and the env-var key is `environment` (not `env`). This differs from hosts like Zed and Cursor.
+
+## Verify Installation
+
+1. Restart OpenCode (or start a new session).
+2. Check that the server is discovered:
+ ```sh
+ opencode mcp list
+ ```
+3. Try a prompt that references the server by name to bias the model toward its tools:
+ ```
+ Use the github tool to list my recently merged pull requests.
+ ```
+
+## Managing the Server
+
+OpenCode exposes a few useful subcommands for MCP servers:
+
+| Command | Purpose |
+| --- | --- |
+| `opencode mcp list` | List configured MCP servers and their auth/connection status. |
+| `opencode mcp debug github` | Show auth status, test HTTP connectivity, and walk through OAuth discovery for the `github` server. |
+| `opencode mcp auth github` | Trigger an OAuth flow manually (only relevant if `oauth` is not set to `false`). |
+| `opencode mcp logout github` | Clear stored OAuth tokens for the server. |
+
+## Disabling Tools Per-Agent
+
+Because the GitHub MCP server can register a large number of tools, you may want to **disable them globally** and **re-enable them only for specific agents**. OpenCode uses the `_*` glob pattern to match all tools from a server:
+
+```json
+{
+ "$schema": "https://opencode.ai/config.json",
+ "mcp": {
+ "github": {
+ "type": "remote",
+ "url": "https://api.githubcopilot.com/mcp/",
+ "enabled": true,
+ "oauth": false,
+ "headers": { "Authorization": "Bearer {env:GITHUB_PERSONAL_ACCESS_TOKEN}" }
+ }
+ },
+ "tools": {
+ "github_*": false
+ },
+ "agent": {
+ "github-helper": {
+ "tools": { "github_*": true }
+ }
+ }
+}
+```
+
+This pattern is recommended by the [OpenCode MCP docs](https://opencode.ai/docs/mcp-servers/) for servers with many tools.
+
+## Troubleshooting
+
+- **`401 Unauthorized` from the remote server**: confirm your PAT is valid and not expired. If you set `oauth: false`, OpenCode will not attempt an OAuth fallback — the `Authorization` header must be correct.
+- **Server marked failed in `opencode mcp list`**: run `opencode mcp debug github` to see the exact connectivity and auth diagnostics.
+- **Tools missing from prompts**: check that `enabled: true` is set on the server and that you have not disabled `github_*` in your `tools` block without re-enabling it for the current agent.
+- **Context window exceeded**: the GitHub MCP server can register many tools. Use server-side toolset filtering (`X-MCP-Toolsets` header) to register only the toolsets you need.
+- **Docker errors on the local server**: ensure Docker Desktop is running and the `ghcr.io/github/github-mcp-server` image has been pulled (`docker pull ghcr.io/github/github-mcp-server`).
+
+## Important Notes
+
+- **Configuration key**: OpenCode uses `mcp` (not `mcpServers` or `context_servers`).
+- **Type discriminator**: every entry must include `"type": "local"` or `"type": "remote"`.
+- **Command shape**: `command` is a single array combining the executable and its arguments.
+- **Environment variable key**: `environment` (not `env`).
+- **OAuth**: enabled by default for remote servers. Set `"oauth": false` when using PAT-in-`Authorization`, otherwise OpenCode may try OAuth first.
+- **Env interpolation**: use `{env:VAR_NAME}` in string values to read from the shell environment instead of hard-coding secrets.
diff --git a/docs/installation-guides/install-zed.md b/docs/installation-guides/install-zed.md
new file mode 100644
index 0000000000..d0e07b6d8e
--- /dev/null
+++ b/docs/installation-guides/install-zed.md
@@ -0,0 +1,103 @@
+# Install GitHub MCP Server in Zed
+
+[Zed](https://zed.dev) is a high-performance multiplayer code editor with native MCP support. Zed exposes MCP servers under the `context_servers` settings key. For general setup information (prerequisites, Docker installation, security best practices), see the [Installation Guides README](./README.md).
+
+## Prerequisites
+
+1. Zed installed (latest version — Zed v0.224.0+ recommended for the modern `agent.tool_permissions` settings shape)
+2. [GitHub Personal Access Token](https://github.com/settings/personal-access-tokens/new) with appropriate scopes
+3. For local installation: [Docker](https://www.docker.com/) installed and running
+
+## Installation Methods
+
+There are two ways to install the GitHub MCP server in Zed:
+
+- **Option A — Zed Extension (easiest):** a community-maintained [GitHub MCP extension](https://zed.dev/extensions/mcp-server-github) is available in the Zed extension gallery. Install it from the Agent Panel's top-right menu → "View Server Extensions", or from the command palette via the `zed: extensions` action. After installation, Zed pops up a modal asking for your GitHub Personal Access Token.
+- **Option B — Custom Server (recommended for the official remote endpoint):** add the configuration manually to `settings.json` to use either GitHub's hosted remote server or the official Docker image directly. The rest of this guide covers Option B.
+
+## Remote Server (Recommended)
+
+Uses GitHub's hosted server at `https://api.githubcopilot.com/mcp/`. Open your Zed [settings file](https://zed.dev/docs/configuring-zed.html#settings-files) (Command Palette → `zed: open settings`) and add the configuration below under `context_servers`.
+
+```json
+{
+ "context_servers": {
+ "github": {
+ "url": "https://api.githubcopilot.com/mcp/",
+ "headers": {
+ "Authorization": "Bearer YOUR_GITHUB_PAT"
+ }
+ }
+ }
+}
+```
+
+Replace `YOUR_GITHUB_PAT` with your [GitHub Personal Access Token](https://github.com/settings/tokens). To customize toolsets, add server-side headers like `X-MCP-Toolsets` or `X-MCP-Readonly` to the `headers` object — see the [Server Configuration Guide](../server-configuration.md).
+
+> [!NOTE]
+> If you omit the `Authorization` header, Zed will attempt the standard MCP OAuth flow on first use. The GitHub MCP server does not currently advertise OAuth for non-Copilot hosts, so a Personal Access Token in the `Authorization` header is the supported path.
+
+## Local Server (Docker)
+
+The local GitHub MCP server runs via Docker and requires Docker Desktop (or another Docker runtime) to be installed and running.
+
+```json
+{
+ "context_servers": {
+ "github": {
+ "command": "docker",
+ "args": [
+ "run", "-i", "--rm",
+ "-e", "GITHUB_PERSONAL_ACCESS_TOKEN",
+ "ghcr.io/github/github-mcp-server"
+ ],
+ "env": {
+ "GITHUB_PERSONAL_ACCESS_TOKEN": "YOUR_GITHUB_PAT"
+ }
+ }
+ }
+}
+```
+
+> [!IMPORTANT]
+> Zed expects `command` as a **string** plus a separate `args` array, not a single array combining both. This differs from hosts like OpenCode and Claude Desktop.
+
+## Verify Installation
+
+1. Open the Agent Panel and click into its Settings view (or run `agent: open settings`).
+2. Find `github` in the context servers list. A green indicator dot with the tooltip "Server is active" confirms a working configuration. Other colors and tooltip messages indicate startup or auth errors.
+3. Try a prompt that should invoke a tool — for example, `List my recent GitHub pull requests`. Zed will prompt for tool approval before the first call unless your `agent.tool_permissions.default` is set to `"allow"`.
+
+## Tool Permissions (Optional)
+
+Zed v0.224.0+ controls tool approval via `agent.tool_permissions`. Approve a specific GitHub MCP tool without per-call prompts by using the `mcp::` key format:
+
+```json
+{
+ "agent": {
+ "tool_permissions": {
+ "default": "confirm",
+ "rules": [
+ { "tool": "mcp:github:list_pull_requests", "permission": "allow" },
+ { "tool": "mcp:github:list_issues", "permission": "allow" }
+ ]
+ }
+ }
+}
+```
+
+See the [Zed tool permissions docs](https://zed.dev/docs/ai/tool-permissions.html) for the full schema.
+
+## Troubleshooting
+
+- **Server indicator stays red / "Server is not running"**: check the Agent Panel's settings view for the per-server error string. Most common cause is invalid JSON in `settings.json` — Zed surfaces JSON parse errors in the editor itself.
+- **`401 Unauthorized`**: verify your PAT has not expired and includes the scopes for the tools you intend to call. The remote endpoint will reject requests with no `Authorization` header (no anonymous access).
+- **Tools missing from prompts**: confirm the Agent profile in use has not disabled the server. If you're using a [custom profile](https://zed.dev/docs/ai/agent-panel.html#custom-profiles), make sure `enable_all_context_servers` is `true` or that `github` is explicitly listed.
+- **Docker errors on the local server**: ensure Docker Desktop is running and the `ghcr.io/github/github-mcp-server` image has been pulled at least once. Try `docker pull ghcr.io/github/github-mcp-server` from a terminal.
+
+## Important Notes
+
+- **Configuration key**: Zed uses `context_servers` (not `mcpServers`).
+- **Command shape**: `command` is a string + separate `args` array.
+- **OAuth**: omitting `Authorization` triggers Zed's MCP OAuth flow, but the GitHub MCP server's PAT-based auth is the supported path today.
+- **External agents**: MCP servers configured in `context_servers` are forwarded to [external agents](https://zed.dev/docs/ai/external-agents.html) via the Agent Client Protocol.
diff --git a/docs/remote-server.md b/docs/remote-server.md
index 5a82f1c2e1..aa083d2f29 100644
--- a/docs/remote-server.md
+++ b/docs/remote-server.md
@@ -19,7 +19,8 @@ Below is a table of available toolsets for the remote GitHub MCP Server. Each to
| Name | Description | API URL | 1-Click Install (VS Code) | Read-only Link | 1-Click Read-only Install (VS Code) |
| ---- | ----------- | ------- | ------------------------- | -------------- | ----------------------------------- |
-| 
`all` | All available GitHub MCP tools | https://api.githubcopilot.com/mcp/ | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2F%22%7D) | [read-only](https://api.githubcopilot.com/mcp/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Freadonly%22%7D) |
+| 
`default` | Default toolset | https://api.githubcopilot.com/mcp/ | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2F%22%7D) | [read-only](https://api.githubcopilot.com/mcp/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Freadonly%22%7D) |
+| 
`all` | All available GitHub MCP tools | https://api.githubcopilot.com/mcp/x/all | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-all&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fall%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/all/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-all&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fall%2Freadonly%22%7D) |
| 
`actions` | GitHub Actions workflows and CI/CD operations | https://api.githubcopilot.com/mcp/x/actions | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-actions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Factions%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/actions/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-actions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Factions%2Freadonly%22%7D) |
| 
`code_security` | Code security related tools, such as GitHub Code Scanning | https://api.githubcopilot.com/mcp/x/code_security | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_security&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcode_security%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/code_security/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_security&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcode_security%2Freadonly%22%7D) |
| 
`copilot` | Copilot related tools | https://api.githubcopilot.com/mcp/x/copilot | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-copilot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcopilot%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/copilot/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-copilot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcopilot%2Freadonly%22%7D) |
diff --git a/pkg/errors/error.go b/pkg/errors/error.go
index 7c1f28e660..a1b35d697d 100644
--- a/pkg/errors/error.go
+++ b/pkg/errors/error.go
@@ -2,8 +2,10 @@ package errors
import (
"context"
+ stderrors "errors"
"fmt"
"net/http"
+ "time"
"github.com/github/github-mcp-server/pkg/utils"
"github.com/google/go-github/v87/github"
@@ -159,6 +161,35 @@ func NewGitHubAPIErrorResponse(ctx context.Context, message string, resp *github
if ctx != nil {
_, _ = addGitHubAPIErrorToContext(ctx, apiErr) // Explicitly ignore error for graceful handling
}
+
+ var rateLimitErr *github.RateLimitError
+ if stderrors.As(err, &rateLimitErr) {
+ resetTime := rateLimitErr.Rate.Reset.Time
+ if !resetTime.IsZero() {
+ retryIn := time.Until(resetTime).Round(time.Second)
+ if retryIn > 0 {
+ return utils.NewToolResultError(fmt.Sprintf(
+ "%s: GitHub API rate limit exceeded. Retry after %v.", message, retryIn))
+ }
+ }
+ return utils.NewToolResultError(fmt.Sprintf(
+ "%s: GitHub API rate limit exceeded. Wait before retrying.", message))
+ }
+
+ var abuseErr *github.AbuseRateLimitError
+ if stderrors.As(err, &abuseErr) {
+ if abuseErr.RetryAfter != nil {
+ retryAfter := abuseErr.RetryAfter.Round(time.Second)
+ if retryAfter > 0 {
+ return utils.NewToolResultError(fmt.Sprintf(
+ "%s: GitHub secondary rate limit exceeded. Retry after %v.",
+ message, retryAfter))
+ }
+ }
+ return utils.NewToolResultError(fmt.Sprintf(
+ "%s: GitHub secondary rate limit exceeded. Wait before retrying.", message))
+ }
+
return utils.NewToolResultErrorFromErr(message, err)
}
diff --git a/pkg/errors/error_test.go b/pkg/errors/error_test.go
index 7459569f2a..77ceb21375 100644
--- a/pkg/errors/error_test.go
+++ b/pkg/errors/error_test.go
@@ -5,8 +5,9 @@ import (
"fmt"
"net/http"
"testing"
-
+ "time"
"github.com/google/go-github/v87/github"
+ "github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -460,3 +461,230 @@ func TestMiddlewareScenario(t *testing.T) {
assert.Contains(t, gqlMessages, "mutation failed")
})
}
+
+// requireErrorText asserts that result is a non-nil MCP tool error and returns its text content.
+func requireErrorText(t *testing.T, result *mcp.CallToolResult) string {
+ t.Helper()
+ require.NotNil(t, result)
+ require.True(t, result.IsError)
+ require.NotEmpty(t, result.Content)
+ text, ok := result.Content[0].(*mcp.TextContent)
+ require.True(t, ok, "expected *mcp.TextContent, got %T", result.Content[0])
+ return text.Text
+}
+
+// assertContextHasError asserts that exactly one error is stored in ctx and it matches expectedErr.
+//
+//nolint:revive // t must be first for test helpers; context-as-argument doesn't apply here
+func assertContextHasError(t *testing.T, ctx context.Context, expectedErr error) {
+ t.Helper()
+ apiErrors, err := GetGitHubAPIErrors(ctx)
+ require.NoError(t, err)
+ require.Len(t, apiErrors, 1)
+ assert.Equal(t, expectedErr, apiErrors[0].Err)
+}
+
+func TestNewGitHubAPIErrorResponse_RateLimits(t *testing.T) {
+ t.Run("RateLimitError produces clean message with retry time", func(t *testing.T) {
+ // Given a context with GitHub error tracking enabled
+ ctx := ContextWithGitHubErrors(context.Background())
+
+ resetTime := time.Now().Add(30 * time.Minute)
+ rateLimitErr := &github.RateLimitError{
+ Rate: github.Rate{Reset: github.Timestamp{Time: resetTime}},
+ Response: &http.Response{StatusCode: 403},
+ Message: "API rate limit exceeded",
+ }
+ resp := &github.Response{Response: rateLimitErr.Response}
+
+ // Capture expected duration before the call so both use the same time.Until snapshot
+ expectedRetryIn := time.Until(resetTime).Round(time.Second)
+
+ // When we create an API error response for a rate limit error
+ result := NewGitHubAPIErrorResponse(ctx, "search code", resp, rateLimitErr)
+
+ // Then the message should be clean and actionable (no raw URLs or status codes)
+ text := requireErrorText(t, result)
+ assert.Contains(t, text, fmt.Sprintf("GitHub API rate limit exceeded. Retry after %v.", expectedRetryIn))
+ assert.NotContains(t, text, "https://")
+ assert.NotContains(t, text, "403")
+
+ // And the original error should still be stored in context for middleware
+ assertContextHasError(t, ctx, rateLimitErr)
+ })
+
+ t.Run("AbuseRateLimitError with RetryAfter produces clean message with wait time", func(t *testing.T) {
+ // Given a context with GitHub error tracking enabled
+ ctx := ContextWithGitHubErrors(context.Background())
+
+ retryAfter := 47 * time.Second
+ abuseErr := &github.AbuseRateLimitError{
+ Response: &http.Response{StatusCode: 403},
+ Message: "You have exceeded a secondary rate limit.",
+ RetryAfter: &retryAfter,
+ }
+ resp := &github.Response{Response: abuseErr.Response}
+
+ // When we create an API error response for a secondary rate limit error
+ result := NewGitHubAPIErrorResponse(ctx, "create issue", resp, abuseErr)
+
+ // And the message should include the specific retry duration
+ text := requireErrorText(t, result)
+ assert.Contains(t, text, "GitHub secondary rate limit exceeded. Retry after 47s.")
+ assert.NotContains(t, text, "https://")
+ assert.NotContains(t, text, "403")
+
+ // And the original error should still be stored in context for middleware
+ assertContextHasError(t, ctx, abuseErr)
+ })
+
+ t.Run("AbuseRateLimitError without RetryAfter produces clean message without wait time", func(t *testing.T) {
+ // Given a context with GitHub error tracking enabled
+ ctx := ContextWithGitHubErrors(context.Background())
+
+ abuseErr := &github.AbuseRateLimitError{
+ Response: &http.Response{StatusCode: 403},
+ Message: "You have exceeded a secondary rate limit.",
+ RetryAfter: nil,
+ }
+ resp := &github.Response{Response: abuseErr.Response}
+
+ // When we create an API error response for a secondary rate limit error without retry info
+ result := NewGitHubAPIErrorResponse(ctx, "create issue", resp, abuseErr)
+
+ // And the message should be clean and actionable
+ text := requireErrorText(t, result)
+ assert.Contains(t, text, "GitHub secondary rate limit exceeded. Wait before retrying.")
+ assert.NotContains(t, text, "https://")
+ assert.NotContains(t, text, "403")
+
+ // And the original error should still be stored in context for middleware
+ assertContextHasError(t, ctx, abuseErr)
+ })
+
+ t.Run("AbuseRateLimitError with sub-second RetryAfter falls back to wait message", func(t *testing.T) {
+ ctx := ContextWithGitHubErrors(context.Background())
+
+ // 200ms rounds to 0s, so should fall back to the generic wait message
+ retryAfter := 200 * time.Millisecond
+ abuseErr := &github.AbuseRateLimitError{
+ Response: &http.Response{StatusCode: 403},
+ Message: "You have exceeded a secondary rate limit.",
+ RetryAfter: &retryAfter,
+ }
+ resp := &github.Response{Response: abuseErr.Response}
+
+ result := NewGitHubAPIErrorResponse(ctx, "create issue", resp, abuseErr)
+
+ text := requireErrorText(t, result)
+ assert.Contains(t, text, "GitHub secondary rate limit exceeded. Wait before retrying.")
+ })
+
+ t.Run("RateLimitError with reset time in the past falls back to wait message", func(t *testing.T) {
+ ctx := ContextWithGitHubErrors(context.Background())
+
+ resetTime := time.Now().Add(-5 * time.Second) // already passed
+ rateLimitErr := &github.RateLimitError{
+ Rate: github.Rate{Reset: github.Timestamp{Time: resetTime}},
+ Response: &http.Response{StatusCode: 403},
+ Message: "API rate limit exceeded",
+ }
+ resp := &github.Response{Response: rateLimitErr.Response}
+
+ result := NewGitHubAPIErrorResponse(ctx, "search code", resp, rateLimitErr)
+
+ text := requireErrorText(t, result)
+ assert.Contains(t, text, "GitHub API rate limit exceeded. Wait before retrying.")
+ })
+
+ t.Run("RateLimitError with sub-second reset time falls back to wait message", func(t *testing.T) {
+ ctx := ContextWithGitHubErrors(context.Background())
+
+ // 250ms in the future: still positive, but rounds to 0s, so should fall back
+ resetTime := time.Now().Add(250 * time.Millisecond)
+ rateLimitErr := &github.RateLimitError{
+ Rate: github.Rate{Reset: github.Timestamp{Time: resetTime}},
+ Response: &http.Response{StatusCode: 403},
+ Message: "API rate limit exceeded",
+ }
+ resp := &github.Response{Response: rateLimitErr.Response}
+
+ result := NewGitHubAPIErrorResponse(ctx, "search code", resp, rateLimitErr)
+
+ text := requireErrorText(t, result)
+ assert.Contains(t, text, "GitHub API rate limit exceeded. Wait before retrying.")
+ })
+
+ t.Run("RateLimitError with zero reset time falls back to wait message", func(t *testing.T) {
+ ctx := ContextWithGitHubErrors(context.Background())
+
+ rateLimitErr := &github.RateLimitError{
+ Rate: github.Rate{}, // zero Reset time
+ Response: &http.Response{StatusCode: 403},
+ Message: "API rate limit exceeded",
+ }
+ resp := &github.Response{Response: rateLimitErr.Response}
+
+ result := NewGitHubAPIErrorResponse(ctx, "search code", resp, rateLimitErr)
+
+ text := requireErrorText(t, result)
+ assert.Contains(t, text, "GitHub API rate limit exceeded. Wait before retrying.")
+ })
+
+ t.Run("wrapped RateLimitError is handled via errors.As", func(t *testing.T) {
+ ctx := ContextWithGitHubErrors(context.Background())
+
+ resetTime := time.Now().Add(20 * time.Minute)
+ rateLimitErr := &github.RateLimitError{
+ Rate: github.Rate{Reset: github.Timestamp{Time: resetTime}},
+ Response: &http.Response{StatusCode: 403},
+ Message: "API rate limit exceeded",
+ }
+ wrappedErr := fmt.Errorf("transport layer: %w", rateLimitErr)
+ resp := &github.Response{Response: rateLimitErr.Response}
+
+ // Capture expected duration before the call so both use the same time.Until snapshot
+ expectedRetryIn := time.Until(resetTime).Round(time.Second)
+
+ result := NewGitHubAPIErrorResponse(ctx, "search code", resp, wrappedErr)
+
+ text := requireErrorText(t, result)
+ assert.Contains(t, text, fmt.Sprintf("GitHub API rate limit exceeded. Retry after %v.", expectedRetryIn))
+ assert.NotContains(t, text, "https://")
+ })
+
+ t.Run("wrapped AbuseRateLimitError is handled via errors.As", func(t *testing.T) {
+ ctx := ContextWithGitHubErrors(context.Background())
+
+ retryAfter := 30 * time.Second
+ abuseErr := &github.AbuseRateLimitError{
+ Response: &http.Response{StatusCode: 403},
+ Message: "secondary rate limit",
+ RetryAfter: &retryAfter,
+ }
+ wrappedErr := fmt.Errorf("transport layer: %w", abuseErr)
+ resp := &github.Response{Response: abuseErr.Response}
+
+ result := NewGitHubAPIErrorResponse(ctx, "create issue", resp, wrappedErr)
+
+ text := requireErrorText(t, result)
+ assert.Contains(t, text, "GitHub secondary rate limit exceeded. Retry after 30s.")
+ assert.NotContains(t, text, "https://")
+ })
+
+ t.Run("non-rate-limit GitHub API error passes through the original error message", func(t *testing.T) {
+ // Given a context with GitHub error tracking enabled
+ ctx := ContextWithGitHubErrors(context.Background())
+
+ resp := &github.Response{Response: &http.Response{StatusCode: 422}}
+ originalErr := fmt.Errorf("validation failed")
+
+ // When we create an API error response for a non-rate-limit error
+ result := NewGitHubAPIErrorResponse(ctx, "API call failed", resp, originalErr)
+
+ // Then the message should contain the original error text unchanged
+ text := requireErrorText(t, result)
+ assert.Contains(t, text, "validation failed")
+ })
+}
+
diff --git a/pkg/github/__toolsnaps__/add_issue_comment.snap b/pkg/github/__toolsnaps__/add_issue_comment.snap
index d273a582d6..5479a16a60 100644
--- a/pkg/github/__toolsnaps__/add_issue_comment.snap
+++ b/pkg/github/__toolsnaps__/add_issue_comment.snap
@@ -1,6 +1,6 @@
{
"annotations": {
- "title": "Add comment to issue"
+ "title": "Add comment to issue or pull request"
},
"description": "Add a comment to a specific issue in a GitHub repository. Use this tool to add comments to pull requests as well (in this case pass pull request number as issue_number), but only if user is not asking specifically to add review comments.",
"inputSchema": {
diff --git a/pkg/github/__toolsnaps__/get_commit.snap b/pkg/github/__toolsnaps__/get_commit.snap
index 9e2346b59d..122e6210b3 100644
--- a/pkg/github/__toolsnaps__/get_commit.snap
+++ b/pkg/github/__toolsnaps__/get_commit.snap
@@ -6,10 +6,15 @@
"description": "Get details for a commit from a GitHub repository",
"inputSchema": {
"properties": {
- "include_diff": {
- "default": true,
- "description": "Whether to include file diffs and stats in the response. Default is true.",
- "type": "boolean"
+ "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"
},
"owner": {
"description": "Repository owner",
diff --git a/pkg/github/__toolsnaps__/get_discussion_comments.snap b/pkg/github/__toolsnaps__/get_discussion_comments.snap
index 422fc40bf7..0dcd7343e7 100644
--- a/pkg/github/__toolsnaps__/get_discussion_comments.snap
+++ b/pkg/github/__toolsnaps__/get_discussion_comments.snap
@@ -7,7 +7,7 @@
"inputSchema": {
"properties": {
"after": {
- "description": "Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs.",
+ "description": "Cursor for pagination. Use the cursor from the previous response.",
"type": "string"
},
"discussionNumber": {
diff --git a/pkg/github/__toolsnaps__/get_file_blame.snap b/pkg/github/__toolsnaps__/get_file_blame.snap
new file mode 100644
index 0000000000..83d09f265c
--- /dev/null
+++ b/pkg/github/__toolsnaps__/get_file_blame.snap
@@ -0,0 +1,54 @@
+{
+ "annotations": {
+ "readOnlyHint": true,
+ "title": "Get file blame information"
+ },
+ "description": "Get git blame information for a file, showing the commit that last modified each line. Ranges share commit metadata via the top-level 'commits' map keyed by SHA. Use 'start_line'/'end_line' to restrict the result to a window of the file, and 'perPage'/'after' to cursor-page through returned ranges. Matching ranges are capped at 1000; when the cap is hit 'truncated' is set to true and 'total_ranges' reports the pre-cap match count.",
+ "inputSchema": {
+ "properties": {
+ "after": {
+ "description": "Cursor for pagination. Use the cursor from the previous response.",
+ "type": "string"
+ },
+ "end_line": {
+ "description": "Optional 1-based ending line of the window of interest. Must be \u003e= start_line when both are provided.",
+ "minimum": 1,
+ "type": "number"
+ },
+ "owner": {
+ "description": "Repository owner (username or organization)",
+ "type": "string"
+ },
+ "path": {
+ "description": "Path to the file in the repository, relative to the repository root",
+ "type": "string"
+ },
+ "perPage": {
+ "description": "Results per page for pagination (min 1, max 100)",
+ "maximum": 100,
+ "minimum": 1,
+ "type": "number"
+ },
+ "ref": {
+ "description": "Git reference (branch, tag, or commit SHA). Defaults to the repository's default branch (HEAD).",
+ "type": "string"
+ },
+ "repo": {
+ "description": "Repository name",
+ "type": "string"
+ },
+ "start_line": {
+ "description": "Optional 1-based starting line of the window of interest. Only ranges overlapping [start_line, end_line] are returned, clamped to the window.",
+ "minimum": 1,
+ "type": "number"
+ }
+ },
+ "required": [
+ "owner",
+ "repo",
+ "path"
+ ],
+ "type": "object"
+ },
+ "name": "get_file_blame"
+}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/issue_write.snap b/pkg/github/__toolsnaps__/issue_write.snap
index a125864f04..88b01f08f1 100644
--- a/pkg/github/__toolsnaps__/issue_write.snap
+++ b/pkg/github/__toolsnaps__/issue_write.snap
@@ -9,7 +9,7 @@
}
},
"annotations": {
- "title": "Create or update issue"
+ "title": "Create or update issue/pull request"
},
"description": "Create a new or update an existing issue in a GitHub repository.",
"inputSchema": {
diff --git a/pkg/github/__toolsnaps__/issue_write_ff_remote_mcp_issue_fields.snap b/pkg/github/__toolsnaps__/issue_write_ff_remote_mcp_issue_fields.snap
index 6fb00d2490..332a4de3e1 100644
--- a/pkg/github/__toolsnaps__/issue_write_ff_remote_mcp_issue_fields.snap
+++ b/pkg/github/__toolsnaps__/issue_write_ff_remote_mcp_issue_fields.snap
@@ -9,7 +9,7 @@
}
},
"annotations": {
- "title": "Create or update issue"
+ "title": "Create or update issue/pull request"
},
"description": "Create a new or update an existing issue in a GitHub repository.",
"inputSchema": {
diff --git a/pkg/github/__toolsnaps__/list_dependabot_alerts.snap b/pkg/github/__toolsnaps__/list_dependabot_alerts.snap
index 55d5437796..5fdbcd2e6f 100644
--- a/pkg/github/__toolsnaps__/list_dependabot_alerts.snap
+++ b/pkg/github/__toolsnaps__/list_dependabot_alerts.snap
@@ -6,15 +6,14 @@
"description": "List dependabot alerts in a GitHub repository.",
"inputSchema": {
"properties": {
+ "after": {
+ "description": "Cursor for pagination. Use the cursor from the previous response.",
+ "type": "string"
+ },
"owner": {
"description": "The owner of the repository.",
"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,
diff --git a/pkg/github/__toolsnaps__/list_discussions.snap b/pkg/github/__toolsnaps__/list_discussions.snap
index 42be769335..fdf5f6d7a3 100644
--- a/pkg/github/__toolsnaps__/list_discussions.snap
+++ b/pkg/github/__toolsnaps__/list_discussions.snap
@@ -7,7 +7,7 @@
"inputSchema": {
"properties": {
"after": {
- "description": "Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs.",
+ "description": "Cursor for pagination. Use the cursor from the previous response.",
"type": "string"
},
"category": {
diff --git a/pkg/github/__toolsnaps__/list_issues.snap b/pkg/github/__toolsnaps__/list_issues.snap
index a4be59bb0c..8ce261d7cb 100644
--- a/pkg/github/__toolsnaps__/list_issues.snap
+++ b/pkg/github/__toolsnaps__/list_issues.snap
@@ -7,7 +7,7 @@
"inputSchema": {
"properties": {
"after": {
- "description": "Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs.",
+ "description": "Cursor for pagination. Use the cursor from the previous response.",
"type": "string"
},
"direction": {
diff --git a/pkg/github/__toolsnaps__/list_issues_ff_remote_mcp_issue_fields.snap b/pkg/github/__toolsnaps__/list_issues_ff_remote_mcp_issue_fields.snap
index b1d1c7a21d..53a951846a 100644
--- a/pkg/github/__toolsnaps__/list_issues_ff_remote_mcp_issue_fields.snap
+++ b/pkg/github/__toolsnaps__/list_issues_ff_remote_mcp_issue_fields.snap
@@ -7,7 +7,7 @@
"inputSchema": {
"properties": {
"after": {
- "description": "Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs.",
+ "description": "Cursor for pagination. Use the cursor from the previous response.",
"type": "string"
},
"direction": {
diff --git a/pkg/github/__toolsnaps__/projects_write.snap b/pkg/github/__toolsnaps__/projects_write.snap
index f6d3197b84..6c9d349f63 100644
--- a/pkg/github/__toolsnaps__/projects_write.snap
+++ b/pkg/github/__toolsnaps__/projects_write.snap
@@ -1,15 +1,19 @@
{
"annotations": {
"destructiveHint": true,
- "title": "Modify GitHub Project items"
+ "title": "Manage GitHub Projects"
},
- "description": "Add, update, or delete project items, or create status updates in a GitHub Project.",
+ "description": "Create and manage GitHub Projects: create projects, add/update/delete items, create status updates, and add 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"
@@ -34,13 +38,46 @@
],
"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_project_status_update",
+ "create_project",
+ "create_iteration_field"
],
"type": "string"
},
@@ -49,7 +86,7 @@
"type": "string"
},
"owner_type": {
- "description": "Owner type (user or org). If not provided, will be automatically detected.",
+ "description": "Owner type (user or org). Required for 'create_project' method. If not provided for other methods, will be automatically detected.",
"enum": [
"user",
"org"
@@ -57,7 +94,7 @@
"type": "string"
},
"project_number": {
- "description": "The project's number.",
+ "description": "The project's number. Required for all methods except 'create_project'.",
"type": "number"
},
"pull_request_number": {
@@ -65,7 +102,7 @@
"type": "number"
},
"start_date": {
- "description": "The start date of the status update in YYYY-MM-DD format. Used for 'create_project_status_update' method.",
+ "description": "Start date in YYYY-MM-DD format. Used for 'create_project_status_update' and 'create_iteration_field' methods.",
"type": "string"
},
"status": {
@@ -83,6 +120,10 @@
"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"
@@ -90,8 +131,7 @@
},
"required": [
"method",
- "owner",
- "project_number"
+ "owner"
],
"type": "object"
},
diff --git a/pkg/github/__toolsnaps__/pull_request_read.snap b/pkg/github/__toolsnaps__/pull_request_read.snap
index d70f77e1e0..f1bb855d51 100644
--- a/pkg/github/__toolsnaps__/pull_request_read.snap
+++ b/pkg/github/__toolsnaps__/pull_request_read.snap
@@ -11,12 +11,13 @@
"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_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 6. 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 7. 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 8. 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",
+ "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",
diff --git a/pkg/github/__toolsnaps__/request_pull_request_reviewers.snap b/pkg/github/__toolsnaps__/request_pull_request_reviewers.snap
index 67b7014474..20f1ab62b6 100644
--- a/pkg/github/__toolsnaps__/request_pull_request_reviewers.snap
+++ b/pkg/github/__toolsnaps__/request_pull_request_reviewers.snap
@@ -21,7 +21,7 @@
"type": "string"
},
"reviewers": {
- "description": "GitHub usernames to request reviews from",
+ "description": "GitHub usernames or ORG/team-slug team reviewers to request reviews from",
"items": {
"type": "string"
},
diff --git a/pkg/github/__toolsnaps__/set_issue_fields.snap b/pkg/github/__toolsnaps__/set_issue_fields.snap
index 88c88fdc65..8f25d09699 100644
--- a/pkg/github/__toolsnaps__/set_issue_fields.snap
+++ b/pkg/github/__toolsnaps__/set_issue_fields.snap
@@ -11,6 +11,15 @@
"description": "Array of issue field values to set. Each element must have a 'field_id' (string, the GraphQL node ID of the field) and exactly one value field: 'text_value' for text fields, 'number_value' for number fields, 'date_value' (ISO 8601 date string) for date fields, or 'single_select_option_id' (the GraphQL node ID of the option) for single select fields. Set 'delete' to true to remove a field value.",
"items": {
"properties": {
+ "confidence": {
+ "description": "How confident you are in this choice. Use 'high' for clear signal or explicit user request, 'medium' for reasonable inference with some ambiguity, 'low' for best guess with limited signal.",
+ "enum": [
+ "low",
+ "medium",
+ "high"
+ ],
+ "type": "string"
+ },
"date_value": {
"description": "The value to set for a date field (ISO 8601 date string)",
"type": "string"
diff --git a/pkg/github/__toolsnaps__/update_issue_labels.snap b/pkg/github/__toolsnaps__/update_issue_labels.snap
index 3bdbdfc9ef..21f7fea6b6 100644
--- a/pkg/github/__toolsnaps__/update_issue_labels.snap
+++ b/pkg/github/__toolsnaps__/update_issue_labels.snap
@@ -4,7 +4,7 @@
"openWorldHint": true,
"title": "Update Issue Labels"
},
- "description": "Update the labels of an existing issue. This replaces the current labels with the provided list.",
+ "description": "Update the labels of an existing issue. This replaces the current labels with the provided list. When setting values, include a confidence level (low, medium, or high) reflecting how certain you are about the choice.",
"inputSchema": {
"properties": {
"issue_number": {
@@ -22,6 +22,15 @@
},
{
"properties": {
+ "confidence": {
+ "description": "How confident you are in this choice. Use 'high' for clear signal or explicit user request, 'medium' for reasonable inference with some ambiguity, 'low' for best guess with limited signal.",
+ "enum": [
+ "low",
+ "medium",
+ "high"
+ ],
+ "type": "string"
+ },
"is_suggestion": {
"description": "If true, this label is sent to the API as a suggestion (suggest:true) rather than an applied label. Whether the label is applied or recorded as a proposal is determined by the API.",
"type": "boolean"
diff --git a/pkg/github/__toolsnaps__/update_issue_type.snap b/pkg/github/__toolsnaps__/update_issue_type.snap
index da749cd466..2f39b2d3b8 100644
--- a/pkg/github/__toolsnaps__/update_issue_type.snap
+++ b/pkg/github/__toolsnaps__/update_issue_type.snap
@@ -4,9 +4,18 @@
"openWorldHint": true,
"title": "Update Issue Type"
},
- "description": "Update the type of an existing issue (e.g. 'bug', 'feature').",
+ "description": "Update the type of an existing issue (e.g. 'bug', 'feature'). When setting values, include a confidence level (low, medium, or high) reflecting how certain you are about the choice.",
"inputSchema": {
"properties": {
+ "confidence": {
+ "description": "How confident you are in this choice. Use 'high' for clear signal or explicit user request, 'medium' for reasonable inference with some ambiguity, 'low' for best guess with limited signal.",
+ "enum": [
+ "low",
+ "medium",
+ "high"
+ ],
+ "type": "string"
+ },
"is_suggestion": {
"description": "If true, this issue type change is sent to the API as a suggestion (suggest:true) rather than an applied value. Whether the type is applied or recorded as a proposal is determined by the API.",
"type": "boolean"
diff --git a/pkg/github/__toolsnaps__/update_pull_request.snap b/pkg/github/__toolsnaps__/update_pull_request.snap
index ef330188ff..3d87fe75fe 100644
--- a/pkg/github/__toolsnaps__/update_pull_request.snap
+++ b/pkg/github/__toolsnaps__/update_pull_request.snap
@@ -34,7 +34,7 @@
"type": "string"
},
"reviewers": {
- "description": "GitHub usernames to request reviews from",
+ "description": "GitHub usernames or ORG/team-slug team reviewers to request reviews from",
"items": {
"type": "string"
},
diff --git a/pkg/github/actions.go b/pkg/github/actions.go
index a7ce039d83..9dac877736 100644
--- a/pkg/github/actions.go
+++ b/pkg/github/actions.go
@@ -12,6 +12,7 @@ import (
"github.com/github/github-mcp-server/internal/profiler"
buffer "github.com/github/github-mcp-server/pkg/buffer"
ghErrors "github.com/github/github-mcp-server/pkg/errors"
+ "github.com/github/github-mcp-server/pkg/ifc"
"github.com/github/github-mcp-server/pkg/inventory"
"github.com/github/github-mcp-server/pkg/scopes"
"github.com/github/github-mcp-server/pkg/translations"
@@ -354,6 +355,14 @@ Use this tool to list workflows in a repository, or list workflow runs, jobs, an
return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err)
}
+ // attachIFC adds the IFC label to a successful Actions result when
+ // IFC labels are enabled. Workflow definitions, runs, jobs,
+ // artifacts and logs echo attacker-influenceable run output, so
+ // integrity is untrusted; confidentiality follows repo visibility.
+ attachIFC := func(r *mcp.CallToolResult) *mcp.CallToolResult {
+ return attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, r, ifc.LabelActionsResult)
+ }
+
var resourceIDInt int64
var parseErr error
switch method {
@@ -376,13 +385,17 @@ Use this tool to list workflows in a repository, or list workflow runs, jobs, an
switch method {
case actionsMethodListWorkflows:
- return listWorkflows(ctx, client, owner, repo, pagination)
+ result, payload, err := listWorkflows(ctx, client, owner, repo, pagination)
+ return attachIFC(result), payload, err
case actionsMethodListWorkflowRuns:
- return listWorkflowRuns(ctx, client, args, owner, repo, resourceID, pagination)
+ result, payload, err := listWorkflowRuns(ctx, client, args, owner, repo, resourceID, pagination)
+ return attachIFC(result), payload, err
case actionsMethodListWorkflowJobs:
- return listWorkflowJobs(ctx, client, args, owner, repo, resourceIDInt, pagination)
+ result, payload, err := listWorkflowJobs(ctx, client, args, owner, repo, resourceIDInt, pagination)
+ return attachIFC(result), payload, err
case actionsMethodListWorkflowArtifacts:
- return listWorkflowArtifacts(ctx, client, owner, repo, resourceIDInt, pagination)
+ result, payload, err := listWorkflowArtifacts(ctx, client, owner, repo, resourceIDInt, pagination)
+ return attachIFC(result), payload, err
default:
return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil
}
@@ -465,6 +478,14 @@ Use this tool to get details about individual workflows, workflow runs, jobs, an
return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err)
}
+ // attachIFC adds the IFC label to a successful Actions result when
+ // IFC labels are enabled. Workflow runs, jobs, artifacts, usage,
+ // and log URLs reflect attacker-influenceable run output, so
+ // integrity is untrusted; confidentiality follows repo visibility.
+ attachIFC := func(r *mcp.CallToolResult) *mcp.CallToolResult {
+ return attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, r, ifc.LabelActionsResult)
+ }
+
var resourceIDInt int64
var parseErr error
switch method {
@@ -480,17 +501,23 @@ Use this tool to get details about individual workflows, workflow runs, jobs, an
switch method {
case actionsMethodGetWorkflow:
- return getWorkflow(ctx, client, owner, repo, resourceID)
+ result, payload, err := getWorkflow(ctx, client, owner, repo, resourceID)
+ return attachIFC(result), payload, err
case actionsMethodGetWorkflowRun:
- return getWorkflowRun(ctx, client, owner, repo, resourceIDInt)
+ result, payload, err := getWorkflowRun(ctx, client, owner, repo, resourceIDInt)
+ return attachIFC(result), payload, err
case actionsMethodGetWorkflowJob:
- return getWorkflowJob(ctx, client, owner, repo, resourceIDInt)
+ result, payload, err := getWorkflowJob(ctx, client, owner, repo, resourceIDInt)
+ return attachIFC(result), payload, err
case actionsMethodDownloadWorkflowArtifact:
- return downloadWorkflowArtifact(ctx, client, owner, repo, resourceIDInt)
+ result, payload, err := downloadWorkflowArtifact(ctx, client, owner, repo, resourceIDInt)
+ return attachIFC(result), payload, err
case actionsMethodGetWorkflowRunUsage:
- return getWorkflowRunUsage(ctx, client, owner, repo, resourceIDInt)
+ result, payload, err := getWorkflowRunUsage(ctx, client, owner, repo, resourceIDInt)
+ return attachIFC(result), payload, err
case actionsMethodGetWorkflowRunLogsURL:
- return getWorkflowRunLogsURL(ctx, client, owner, repo, resourceIDInt)
+ result, payload, err := getWorkflowRunLogsURL(ctx, client, owner, repo, resourceIDInt)
+ return attachIFC(result), payload, err
default:
return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil
}
@@ -719,12 +746,22 @@ For single job logs, provide job_id. For all failed jobs in a run, provide run_i
return utils.NewToolResultError("job_id is required when failed_only is false"), nil, nil
}
+ // attachIFC adds the IFC label to a successful result when IFC
+ // labels are enabled. Job logs echo attacker-influenceable run
+ // output, so integrity is untrusted; confidentiality follows repo
+ // visibility.
+ attachIFC := func(r *mcp.CallToolResult) *mcp.CallToolResult {
+ return attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, r, ifc.LabelActionsResult)
+ }
+
if failedOnly && runID > 0 {
// Handle failed-only mode: get logs for all failed jobs in the workflow run
- return handleFailedJobLogs(ctx, client, owner, repo, int64(runID), returnContent, tailLines, deps.GetContentWindowSize())
+ result, payload, err := handleFailedJobLogs(ctx, client, owner, repo, int64(runID), returnContent, tailLines, deps.GetContentWindowSize())
+ return attachIFC(result), payload, err
} else if jobID > 0 {
// Handle single job mode
- return handleSingleJobLogs(ctx, client, owner, repo, int64(jobID), returnContent, tailLines, deps.GetContentWindowSize())
+ result, payload, err := handleSingleJobLogs(ctx, client, owner, repo, int64(jobID), returnContent, tailLines, deps.GetContentWindowSize())
+ return attachIFC(result), payload, err
}
return utils.NewToolResultError("Either job_id must be provided for single job logs, or run_id with failed_only=true for failed job logs"), nil, nil
diff --git a/pkg/github/code_scanning.go b/pkg/github/code_scanning.go
index 44307513bb..fb8b7a79c8 100644
--- a/pkg/github/code_scanning.go
+++ b/pkg/github/code_scanning.go
@@ -7,6 +7,7 @@ import (
"net/http"
ghErrors "github.com/github/github-mcp-server/pkg/errors"
+ "github.com/github/github-mcp-server/pkg/ifc"
"github.com/github/github-mcp-server/pkg/inventory"
"github.com/github/github-mcp-server/pkg/scopes"
"github.com/github/github-mcp-server/pkg/translations"
@@ -88,7 +89,12 @@ func GetCodeScanningAlert(t translations.TranslationHelperFunc) inventory.Server
return utils.NewToolResultErrorFromErr("failed to marshal alert", err), nil, nil
}
- return utils.NewToolResultText(string(r)), nil, nil
+ result := utils.NewToolResultText(string(r))
+ // Code scanning alerts are access-restricted regardless of repo
+ // visibility and embed attacker-influenceable code snippets, so the
+ // label is always private-untrusted.
+ result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelSecurityAlert())
+ return result, nil, nil
},
)
}
@@ -208,7 +214,12 @@ func ListCodeScanningAlerts(t translations.TranslationHelperFunc) inventory.Serv
return utils.NewToolResultErrorFromErr("failed to marshal alerts", err), nil, nil
}
- return utils.NewToolResultText(string(r)), nil, nil
+ result := utils.NewToolResultText(string(r))
+ // Code scanning alerts are access-restricted regardless of repo
+ // visibility and embed attacker-influenceable code snippets, so the
+ // label is always private-untrusted.
+ result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelSecurityAlert())
+ return result, nil, nil
},
)
}
diff --git a/pkg/github/context_tools.go b/pkg/github/context_tools.go
index 4008c2f4aa..b4c7098c56 100644
--- a/pkg/github/context_tools.go
+++ b/pkg/github/context_tools.go
@@ -106,12 +106,7 @@ func GetMe(t translations.TranslationHelperFunc) inventory.ServerTool {
}
result := MarshalledTextResult(minimalUser)
- if deps.IsFeatureEnabled(ctx, FeatureFlagIFCLabels) {
- if result.Meta == nil {
- result.Meta = mcp.Meta{}
- }
- result.Meta["ifc"] = ifc.LabelGetMe()
- }
+ result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelGetMe())
return result, nil, nil
},
)
@@ -221,7 +216,12 @@ func GetTeams(t translations.TranslationHelperFunc) inventory.ServerTool {
organizations = append(organizations, orgTeams)
}
- return MarshalledTextResult(organizations), nil, nil
+ result := MarshalledTextResult(organizations)
+ // Team membership is maintained by GitHub and cannot be forged by
+ // outside contributors (trusted). Org team rosters are visible only
+ // to org members, so confidentiality is private.
+ result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelTeam())
+ return result, nil, nil
},
)
}
@@ -292,7 +292,12 @@ func GetTeamMembers(t translations.TranslationHelperFunc) inventory.ServerTool {
members = append(members, string(member.Login))
}
- return MarshalledTextResult(members), nil, nil
+ result := MarshalledTextResult(members)
+ // Team membership is maintained by GitHub and cannot be forged by
+ // outside contributors (trusted). A team's member roster is visible
+ // only to org members, so confidentiality is private.
+ result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelTeam())
+ return result, nil, nil
},
)
}
diff --git a/pkg/github/context_tools_test.go b/pkg/github/context_tools_test.go
index ade54aba17..082b467135 100644
--- a/pkg/github/context_tools_test.go
+++ b/pkg/github/context_tools_test.go
@@ -199,7 +199,9 @@ func Test_GetMe_IFC_FeatureFlag(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, "trusted", ifcMap["integrity"])
- assert.Equal(t, "public", ifcMap["confidentiality"])
+ // get_me returns the caller's private repo/gist counts, which are not
+ // part of the public profile, so confidentiality is private.
+ assert.Equal(t, "private", ifcMap["confidentiality"])
})
}
diff --git a/pkg/github/dependabot.go b/pkg/github/dependabot.go
index 02023da69f..1ac6b1b44c 100644
--- a/pkg/github/dependabot.go
+++ b/pkg/github/dependabot.go
@@ -8,6 +8,7 @@ import (
"net/http"
ghErrors "github.com/github/github-mcp-server/pkg/errors"
+ "github.com/github/github-mcp-server/pkg/ifc"
"github.com/github/github-mcp-server/pkg/inventory"
"github.com/github/github-mcp-server/pkg/scopes"
"github.com/github/github-mcp-server/pkg/translations"
@@ -89,7 +90,12 @@ func GetDependabotAlert(t translations.TranslationHelperFunc) inventory.ServerTo
return utils.NewToolResultErrorFromErr("failed to marshal alert", err), nil, err
}
- return utils.NewToolResultText(string(r)), nil, nil
+ result := utils.NewToolResultText(string(r))
+ // Dependabot alerts are access-restricted regardless of repo
+ // visibility and embed attacker-influenceable advisory text, so the
+ // label is always private-untrusted.
+ result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelSecurityAlert())
+ return result, nil, nil
},
)
}
@@ -120,7 +126,7 @@ func ListDependabotAlerts(t translations.TranslationHelperFunc) inventory.Server
},
Required: []string{"owner", "repo"},
}
- WithPagination(schema)
+ WithCursorPagination(schema)
return NewTool(
ToolsetMetadataDependabot,
@@ -152,7 +158,7 @@ func ListDependabotAlerts(t translations.TranslationHelperFunc) inventory.Server
return utils.NewToolResultError(err.Error()), nil, nil
}
- pagination, err := OptionalPaginationParams(args)
+ pagination, err := OptionalCursorPaginationParams(args)
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
@@ -165,9 +171,9 @@ func ListDependabotAlerts(t translations.TranslationHelperFunc) inventory.Server
alerts, resp, err := client.Dependabot.ListRepoAlerts(ctx, owner, repo, &github.ListAlertsOptions{
State: ToStringPtr(state),
Severity: ToStringPtr(severity),
- ListOptions: github.ListOptions{
- Page: pagination.Page,
+ ListCursorOptions: github.ListCursorOptions{
PerPage: pagination.PerPage,
+ After: pagination.After,
},
})
if err != nil {
@@ -187,12 +193,22 @@ func ListDependabotAlerts(t translations.TranslationHelperFunc) inventory.Server
return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to list alerts", resp, body), nil, nil
}
- r, err := json.Marshal(alerts)
+ response := map[string]any{
+ "alerts": alerts,
+ "pageInfo": buildPageInfo(resp),
+ }
+
+ r, err := json.Marshal(response)
if err != nil {
return utils.NewToolResultErrorFromErr("failed to marshal alerts", err), nil, err
}
- return utils.NewToolResultText(string(r)), nil, nil
+ result := utils.NewToolResultText(string(r))
+ // Dependabot alerts are access-restricted regardless of repo
+ // visibility and embed attacker-influenceable advisory text, so the
+ // label is always private-untrusted.
+ result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelSecurityAlert())
+ return result, nil, nil
},
)
}
diff --git a/pkg/github/dependabot_test.go b/pkg/github/dependabot_test.go
index 7811483908..5236c6d349 100644
--- a/pkg/github/dependabot_test.go
+++ b/pkg/github/dependabot_test.go
@@ -154,19 +154,19 @@ func Test_ListDependabotAlerts(t *testing.T) {
}
tests := []struct {
- name string
- mockedClient *http.Client
- requestArgs map[string]any
- expectError bool
- expectedAlerts []*github.DependabotAlert
- expectedErrMsg string
+ name string
+ mockedClient *http.Client
+ requestArgs map[string]any
+ expectError bool
+ expectedAlerts []*github.DependabotAlert
+ expectedNextCursor string
+ expectedErrMsg string
}{
{
name: "successful open alerts listing",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetReposDependabotAlertsByOwnerByRepo: expectQueryParams(t, map[string]string{
"state": "open",
- "page": "1",
"per_page": "30",
}).andThen(
mockResponse(t, http.StatusOK, []*github.DependabotAlert{&criticalAlert}),
@@ -185,7 +185,6 @@ func Test_ListDependabotAlerts(t *testing.T) {
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetReposDependabotAlertsByOwnerByRepo: expectQueryParams(t, map[string]string{
"severity": "high",
- "page": "1",
"per_page": "30",
}).andThen(
mockResponse(t, http.StatusOK, []*github.DependabotAlert{&highSeverityAlert}),
@@ -203,7 +202,6 @@ func Test_ListDependabotAlerts(t *testing.T) {
name: "successful all alerts listing",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetReposDependabotAlertsByOwnerByRepo: expectQueryParams(t, map[string]string{
- "page": "1",
"per_page": "30",
}).andThen(
mockResponse(t, http.StatusOK, []*github.DependabotAlert{&criticalAlert, &highSeverityAlert}),
@@ -217,10 +215,10 @@ func Test_ListDependabotAlerts(t *testing.T) {
expectedAlerts: []*github.DependabotAlert{&criticalAlert, &highSeverityAlert},
},
{
- name: "successful alerts listing with custom pagination",
+ name: "successful alerts listing with cursor pagination",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetReposDependabotAlertsByOwnerByRepo: expectQueryParams(t, map[string]string{
- "page": "3",
+ "after": "Y3Vyc29yOnYyOpK5",
"per_page": "100",
}).andThen(
mockResponse(t, http.StatusOK, []*github.DependabotAlert{&criticalAlert}),
@@ -229,12 +227,35 @@ func Test_ListDependabotAlerts(t *testing.T) {
requestArgs: map[string]any{
"owner": "owner",
"repo": "repo",
- "page": float64(3),
+ "after": "Y3Vyc29yOnYyOpK5",
"perPage": float64(100),
},
expectError: false,
expectedAlerts: []*github.DependabotAlert{&criticalAlert},
},
+ {
+ name: "successful alerts listing surfaces next page cursor",
+ mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
+ GetReposDependabotAlertsByOwnerByRepo: expectQueryParams(t, map[string]string{
+ "per_page": "30",
+ }).andThen(
+ func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Link", `; rel="next"`)
+ w.WriteHeader(http.StatusOK)
+ b, err := json.Marshal([]*github.DependabotAlert{&criticalAlert})
+ require.NoError(t, err)
+ _, _ = w.Write(b)
+ },
+ ),
+ }),
+ requestArgs: map[string]any{
+ "owner": "owner",
+ "repo": "repo",
+ },
+ expectError: false,
+ expectedAlerts: []*github.DependabotAlert{&criticalAlert},
+ expectedNextCursor: "nextcursor123",
+ },
{
name: "alerts listing fails",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
@@ -291,11 +312,17 @@ func Test_ListDependabotAlerts(t *testing.T) {
textContent := getTextResult(t, result)
// Unmarshal and verify the result
- var returnedAlerts []*github.DependabotAlert
- err = json.Unmarshal([]byte(textContent.Text), &returnedAlerts)
+ var returnedResult struct {
+ Alerts []*github.DependabotAlert `json:"alerts"`
+ PageInfo struct {
+ HasNextPage bool `json:"hasNextPage"`
+ NextCursor string `json:"nextCursor"`
+ } `json:"pageInfo"`
+ }
+ err = json.Unmarshal([]byte(textContent.Text), &returnedResult)
assert.NoError(t, err)
- assert.Len(t, returnedAlerts, len(tc.expectedAlerts))
- for i, alert := range returnedAlerts {
+ assert.Len(t, returnedResult.Alerts, len(tc.expectedAlerts))
+ for i, alert := range returnedResult.Alerts {
assert.Equal(t, *tc.expectedAlerts[i].Number, *alert.Number)
assert.Equal(t, *tc.expectedAlerts[i].HTMLURL, *alert.HTMLURL)
assert.Equal(t, *tc.expectedAlerts[i].State, *alert.State)
@@ -304,6 +331,8 @@ func Test_ListDependabotAlerts(t *testing.T) {
assert.Equal(t, *tc.expectedAlerts[i].SecurityAdvisory.Severity, *alert.SecurityAdvisory.Severity)
}
}
+ assert.Equal(t, tc.expectedNextCursor, returnedResult.PageInfo.NextCursor)
+ assert.Equal(t, tc.expectedNextCursor != "", returnedResult.PageInfo.HasNextPage)
})
}
}
diff --git a/pkg/github/discussions.go b/pkg/github/discussions.go
index 514a2d030d..1f94597739 100644
--- a/pkg/github/discussions.go
+++ b/pkg/github/discussions.go
@@ -6,6 +6,7 @@ import (
"fmt"
"strings"
+ "github.com/github/github-mcp-server/pkg/ifc"
"github.com/github/github-mcp-server/pkg/inventory"
"github.com/github/github-mcp-server/pkg/scopes"
"github.com/github/github-mcp-server/pkg/translations"
@@ -272,7 +273,11 @@ func ListDiscussions(t translations.TranslationHelperFunc) inventory.ServerTool
if err != nil {
return nil, nil, fmt.Errorf("failed to marshal discussions: %w", err)
}
- return utils.NewToolResultText(string(out)), nil, nil
+ result := utils.NewToolResultText(string(out))
+ // Discussion content is user-authored (untrusted); confidentiality
+ // follows repo visibility.
+ result = attachRepoVisibilityIFCLabelLazy(ctx, deps, owner, repo, result, ifc.LabelListIssues)
+ return result, nil, nil
},
)
}
@@ -376,7 +381,11 @@ func GetDiscussion(t translations.TranslationHelperFunc) inventory.ServerTool {
return nil, nil, fmt.Errorf("failed to marshal discussion: %w", err)
}
- return utils.NewToolResultText(string(out)), nil, nil
+ result := utils.NewToolResultText(string(out))
+ // Discussion content is user-authored (untrusted); confidentiality
+ // follows repo visibility.
+ result = attachRepoVisibilityIFCLabelLazy(ctx, deps, params.Owner, params.Repo, result, ifc.LabelListIssues)
+ return result, nil, nil
},
)
}
@@ -580,7 +589,11 @@ func GetDiscussionComments(t translations.TranslationHelperFunc) inventory.Serve
return nil, nil, fmt.Errorf("failed to marshal comments: %w", err)
}
- return utils.NewToolResultText(string(out)), nil, nil
+ result := utils.NewToolResultText(string(out))
+ // Discussion comments are user-authored (untrusted); confidentiality
+ // follows repo visibility.
+ result = attachRepoVisibilityIFCLabelLazy(ctx, deps, params.Owner, params.Repo, result, ifc.LabelListIssues)
+ return result, nil, nil
},
)
}
@@ -1084,7 +1097,11 @@ func ListDiscussionCategories(t translations.TranslationHelperFunc) inventory.Se
if err != nil {
return nil, nil, fmt.Errorf("failed to marshal discussion categories: %w", err)
}
- return utils.NewToolResultText(string(out)), nil, nil
+ result := utils.NewToolResultText(string(out))
+ // Discussion categories are repo-defined structural metadata
+ // (trusted); confidentiality follows repo visibility.
+ result = attachRepoVisibilityIFCLabelLazy(ctx, deps, owner, repo, result, ifc.LabelRepoMetadata)
+ return result, nil, nil
},
)
}
diff --git a/pkg/github/feature_flags.go b/pkg/github/feature_flags.go
index 0f77f6c872..8351795327 100644
--- a/pkg/github/feature_flags.go
+++ b/pkg/github/feature_flags.go
@@ -16,6 +16,11 @@ const FeatureFlagIFCLabels = "ifc_labels"
// and field_values enrichment in list_issues / search_issues output.
const FeatureFlagIssueFields = "remote_mcp_issue_fields"
+// FeatureFlagFileBlame is the feature flag name for the get_file_blame tool,
+// which exposes git blame information for a file. It is gated so the extra tool
+// is not advertised by default, keeping the tool surface small unless opted in.
+const FeatureFlagFileBlame = "file_blame"
+
// AllowedFeatureFlags is the allowlist of feature flags that can be enabled
// by users via --features CLI flag or X-MCP-Features HTTP header.
// Only flags in this list are accepted; unknown flags are silently ignored.
@@ -27,6 +32,7 @@ var AllowedFeatureFlags = []string{
FeatureFlagIssueFields,
FeatureFlagIssuesGranular,
FeatureFlagPullRequestsGranular,
+ FeatureFlagFileBlame,
}
// InsidersFeatureFlags is the list of feature flags that insiders mode enables.
@@ -36,8 +42,8 @@ var AllowedFeatureFlags = []string{
var InsidersFeatureFlags = []string{
MCPAppsFeatureFlag,
FeatureFlagCSVOutput,
- FeatureFlagIFCLabels,
FeatureFlagIssueFields,
+ FeatureFlagFileBlame,
}
// FeatureFlags defines runtime feature toggles that adjust tool behavior.
diff --git a/pkg/github/feature_flags_test.go b/pkg/github/feature_flags_test.go
index 3f9d211953..acb0da1bcd 100644
--- a/pkg/github/feature_flags_test.go
+++ b/pkg/github/feature_flags_test.go
@@ -162,10 +162,10 @@ func TestResolveFeatureFlags(t *testing.T) {
expectedFlags: InsidersFeatureFlags,
},
{
- name: "insiders mode enables internal-only flags",
+ name: "insiders mode does not auto-enable ifc labels",
enabledFeatures: nil,
insidersMode: true,
- expectedFlags: []string{FeatureFlagIFCLabels},
+ unexpectedFlags: []string{FeatureFlagIFCLabels},
},
{
name: "ifc_labels can be directly enabled",
diff --git a/pkg/github/gists.go b/pkg/github/gists.go
index de577af04d..2eacabe4bc 100644
--- a/pkg/github/gists.go
+++ b/pkg/github/gists.go
@@ -8,6 +8,7 @@ import (
"net/http"
ghErrors "github.com/github/github-mcp-server/pkg/errors"
+ "github.com/github/github-mcp-server/pkg/ifc"
"github.com/github/github-mcp-server/pkg/inventory"
"github.com/github/github-mcp-server/pkg/scopes"
"github.com/github/github-mcp-server/pkg/translations"
@@ -99,7 +100,15 @@ func ListGists(t translations.TranslationHelperFunc) inventory.ServerTool {
return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil
}
- return utils.NewToolResultText(string(r)), nil, nil
+ result := utils.NewToolResultText(string(r))
+ // Gist contents are user-authored (untrusted); confidentiality is
+ // the IFC join of each gist's own public/secret flag.
+ visibilities := make([]bool, 0, len(gists))
+ for _, g := range gists {
+ visibilities = append(visibilities, g.GetPublic())
+ }
+ result = attachJoinedIFCLabel(ctx, deps, result, visibilities, ifc.LabelGistList)
+ return result, nil, nil
},
)
}
@@ -157,7 +166,11 @@ func GetGist(t translations.TranslationHelperFunc) inventory.ServerTool {
return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil
}
- return utils.NewToolResultText(string(r)), nil, nil
+ result := utils.NewToolResultText(string(r))
+ // Gist contents are user-authored (untrusted); confidentiality
+ // derives from the gist's own public/secret flag.
+ result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelGist(gist.GetPublic()))
+ return result, nil, nil
},
)
}
diff --git a/pkg/github/git.go b/pkg/github/git.go
index 515d8b65f8..bf88aad770 100644
--- a/pkg/github/git.go
+++ b/pkg/github/git.go
@@ -7,6 +7,7 @@ import (
"strings"
ghErrors "github.com/github/github-mcp-server/pkg/errors"
+ "github.com/github/github-mcp-server/pkg/ifc"
"github.com/github/github-mcp-server/pkg/inventory"
"github.com/github/github-mcp-server/pkg/scopes"
"github.com/github/github-mcp-server/pkg/translations"
@@ -171,7 +172,13 @@ func GetRepositoryTree(t translations.TranslationHelperFunc) inventory.ServerToo
return nil, nil, fmt.Errorf("failed to marshal response: %w", err)
}
- return utils.NewToolResultText(string(r)), nil, nil
+ result := utils.NewToolResultText(string(r))
+ // The repository tree exposes committed file structure; in public
+ // repos anyone can land content via a PR (untrusted), in private
+ // repos only collaborators can (trusted). Confidentiality follows
+ // repo visibility.
+ result = attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, result, ifc.LabelCommitContents)
+ return result, nil, nil
},
)
}
diff --git a/pkg/github/granular_tools_test.go b/pkg/github/granular_tools_test.go
index 90b42b22c5..27e8079f97 100644
--- a/pkg/github/granular_tools_test.go
+++ b/pkg/github/granular_tools_test.go
@@ -8,6 +8,8 @@ import (
"github.com/github/github-mcp-server/internal/githubv4mock"
"github.com/github/github-mcp-server/internal/toolsnaps"
+ "github.com/github/github-mcp-server/pkg/http/headers"
+ transportpkg "github.com/github/github-mcp-server/pkg/http/transport"
"github.com/github/github-mcp-server/pkg/inventory"
"github.com/github/github-mcp-server/pkg/translations"
gogithub "github.com/google/go-github/v87/github"
@@ -461,6 +463,91 @@ func TestGranularUpdateIssueLabelsInvalidRationale(t *testing.T) {
}
}
+func TestGranularUpdateIssueLabelsConfidence(t *testing.T) {
+ tests := []struct {
+ name string
+ requestArgs map[string]any
+ expectedReq map[string]any
+ }{
+ {
+ name: "label with confidence triggers object form",
+ requestArgs: map[string]any{
+ "owner": "owner",
+ "repo": "repo",
+ "issue_number": float64(1),
+ "labels": []any{
+ map[string]any{"name": "bug", "confidence": "high"},
+ },
+ },
+ expectedReq: map[string]any{
+ "labels": []any{
+ map[string]any{"name": "bug", "confidence": "high"},
+ },
+ },
+ },
+ {
+ name: "label with confidence and rationale",
+ requestArgs: map[string]any{
+ "owner": "owner",
+ "repo": "repo",
+ "issue_number": float64(1),
+ "labels": []any{
+ map[string]any{"name": "bug", "rationale": "Reports a crash", "confidence": "medium"},
+ },
+ },
+ expectedReq: map[string]any{
+ "labels": []any{
+ map[string]any{"name": "bug", "rationale": "Reports a crash", "confidence": "medium"},
+ },
+ },
+ },
+ {
+ name: "invalid confidence value",
+ requestArgs: map[string]any{
+ "owner": "owner",
+ "repo": "repo",
+ "issue_number": float64(1),
+ "labels": []any{
+ map[string]any{"name": "bug", "confidence": "very_high"},
+ },
+ },
+ expectedReq: nil,
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ if tc.expectedReq == nil {
+ // Error case
+ deps := BaseDeps{Client: mustNewGHClient(t, MockHTTPClientWithHandlers(nil))}
+ serverTool := GranularUpdateIssueLabels(translations.NullTranslationHelper)
+ handler := serverTool.Handler(deps)
+
+ request := createMCPRequest(tc.requestArgs)
+ result, err := handler(ContextWithDeps(context.Background(), deps), &request)
+ require.NoError(t, err)
+
+ errorContent := getErrorResult(t, result)
+ assert.Contains(t, errorContent.Text, "confidence must be one of: low, medium, high")
+ return
+ }
+
+ client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
+ PatchReposIssuesByOwnerByRepoByIssueNumber: expectRequestBody(t, tc.expectedReq).
+ andThen(mockResponse(t, http.StatusOK, &gogithub.Issue{Number: gogithub.Ptr(1)})),
+ }))
+ deps := BaseDeps{Client: client}
+ serverTool := GranularUpdateIssueLabels(translations.NullTranslationHelper)
+ handler := serverTool.Handler(deps)
+
+ request := createMCPRequest(tc.requestArgs)
+ result, err := handler(ContextWithDeps(context.Background(), deps), &request)
+ require.NoError(t, err)
+ assert.False(t, result.IsError)
+ })
+ }
+}
+
func TestGranularUpdateIssueMilestone(t *testing.T) {
client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
PatchReposIssuesByOwnerByRepoByIssueNumber: expectRequestBody(t, map[string]any{
@@ -642,6 +729,128 @@ func TestGranularUpdateIssueTypeInvalidRationale(t *testing.T) {
}
}
+func TestGranularUpdateIssueTypeConfidence(t *testing.T) {
+ tests := []struct {
+ name string
+ requestArgs map[string]any
+ expectedReq map[string]any
+ }{
+ {
+ name: "type with confidence only",
+ requestArgs: map[string]any{
+ "owner": "owner",
+ "repo": "repo",
+ "issue_number": float64(1),
+ "issue_type": "bug",
+ "confidence": "high",
+ },
+ expectedReq: map[string]any{
+ "type": map[string]any{
+ "value": "bug",
+ "confidence": "high",
+ },
+ },
+ },
+ {
+ name: "type with confidence and rationale",
+ requestArgs: map[string]any{
+ "owner": "owner",
+ "repo": "repo",
+ "issue_number": float64(1),
+ "issue_type": "feature",
+ "rationale": "Asks for dark mode support",
+ "confidence": "medium",
+ },
+ expectedReq: map[string]any{
+ "type": map[string]any{
+ "value": "feature",
+ "rationale": "Asks for dark mode support",
+ "confidence": "medium",
+ },
+ },
+ },
+ {
+ name: "type with low confidence triggers object form",
+ requestArgs: map[string]any{
+ "owner": "owner",
+ "repo": "repo",
+ "issue_number": float64(1),
+ "issue_type": "bug",
+ "confidence": "low",
+ },
+ expectedReq: map[string]any{
+ "type": map[string]any{
+ "value": "bug",
+ "confidence": "low",
+ },
+ },
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
+ PatchReposIssuesByOwnerByRepoByIssueNumber: expectRequestBody(t, tc.expectedReq).
+ andThen(mockResponse(t, http.StatusOK, &gogithub.Issue{Number: gogithub.Ptr(1)})),
+ }))
+ deps := BaseDeps{Client: client}
+ serverTool := GranularUpdateIssueType(translations.NullTranslationHelper)
+ handler := serverTool.Handler(deps)
+
+ request := createMCPRequest(tc.requestArgs)
+ result, err := handler(ContextWithDeps(context.Background(), deps), &request)
+ require.NoError(t, err)
+ assert.False(t, result.IsError)
+ })
+ }
+}
+
+func TestGranularUpdateIssueTypeInvalidConfidence(t *testing.T) {
+ tests := []struct {
+ name string
+ requestArgs map[string]any
+ expectedErrText string
+ }{
+ {
+ name: "invalid confidence value",
+ requestArgs: map[string]any{
+ "owner": "owner",
+ "repo": "repo",
+ "issue_number": float64(1),
+ "issue_type": "bug",
+ "confidence": "very_high",
+ },
+ expectedErrText: "confidence must be one of: low, medium, high",
+ },
+ {
+ name: "confidence wrong type",
+ requestArgs: map[string]any{
+ "owner": "owner",
+ "repo": "repo",
+ "issue_number": float64(1),
+ "issue_type": "bug",
+ "confidence": float64(85),
+ },
+ expectedErrText: "parameter confidence is not of type string",
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ deps := BaseDeps{Client: mustNewGHClient(t, MockHTTPClientWithHandlers(nil))}
+ serverTool := GranularUpdateIssueType(translations.NullTranslationHelper)
+ handler := serverTool.Handler(deps)
+
+ request := createMCPRequest(tc.requestArgs)
+ result, err := handler(ContextWithDeps(context.Background(), deps), &request)
+ require.NoError(t, err)
+
+ errorContent := getErrorResult(t, result)
+ assert.Contains(t, errorContent.Text, tc.expectedErrText)
+ })
+ }
+}
+
func TestGranularUpdateIssueState(t *testing.T) {
tests := []struct {
name string
@@ -773,7 +982,10 @@ func TestGranularUpdatePullRequestState(t *testing.T) {
func TestGranularRequestPullRequestReviewers(t *testing.T) {
client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
- PostReposPullsRequestedReviewersByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, &gogithub.PullRequest{Number: gogithub.Ptr(1)}),
+ PostReposPullsRequestedReviewersByOwnerByRepoByPullNumber: expectRequestBody(t, map[string]any{
+ "reviewers": []any{"user1"},
+ "team_reviewers": []any{"team1"},
+ }).andThen(mockResponse(t, http.StatusOK, &gogithub.PullRequest{Number: gogithub.Ptr(1)})),
}))
deps := BaseDeps{Client: client}
serverTool := GranularRequestPullRequestReviewers(translations.NullTranslationHelper)
@@ -783,7 +995,7 @@ func TestGranularRequestPullRequestReviewers(t *testing.T) {
"owner": "owner",
"repo": "repo",
"pullNumber": float64(1),
- "reviewers": []string{"user1", "user2"},
+ "reviewers": []string{"user1", "owner/team1"},
})
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
@@ -1386,6 +1598,211 @@ func TestGranularSetIssueFields(t *testing.T) {
assert.Contains(t, textContent.Text, "field rationale must be 280 characters or less")
})
+ t.Run("successful set with confidence", func(t *testing.T) {
+ confidence := "high"
+ matchers := []githubv4mock.Matcher{
+ githubv4mock.NewQueryMatcher(
+ struct {
+ Repository struct {
+ Issue struct {
+ ID githubv4.ID
+ } `graphql:"issue(number: $issueNumber)"`
+ } `graphql:"repository(owner: $owner, name: $repo)"`
+ }{},
+ map[string]any{
+ "owner": githubv4.String("owner"),
+ "repo": githubv4.String("repo"),
+ "issueNumber": githubv4.Int(5),
+ },
+ githubv4mock.DataResponse(map[string]any{
+ "repository": map[string]any{
+ "issue": map[string]any{"id": "ISSUE_123"},
+ },
+ }),
+ ),
+ githubv4mock.NewMutationMatcher(
+ struct {
+ SetIssueFieldValue struct {
+ Issue struct {
+ ID githubv4.ID
+ Number githubv4.Int
+ URL githubv4.String
+ }
+ IssueFieldValues []struct {
+ TextValue struct {
+ Value string
+ } `graphql:"... on IssueFieldTextValue"`
+ SingleSelectValue struct {
+ Name string
+ } `graphql:"... on IssueFieldSingleSelectValue"`
+ DateValue struct {
+ Value string
+ } `graphql:"... on IssueFieldDateValue"`
+ NumberValue struct {
+ Value float64
+ } `graphql:"... on IssueFieldNumberValue"`
+ }
+ } `graphql:"setIssueFieldValue(input: $input)"`
+ }{},
+ SetIssueFieldValueInput{
+ IssueID: githubv4.ID("ISSUE_123"),
+ IssueFields: []IssueFieldCreateOrUpdateInput{
+ {
+ FieldID: githubv4.ID("FIELD_1"),
+ TextValue: githubv4.NewString(githubv4.String("hello")),
+ Confidence: &confidence,
+ },
+ },
+ },
+ nil,
+ githubv4mock.DataResponse(map[string]any{
+ "setIssueFieldValue": map[string]any{
+ "issue": map[string]any{
+ "id": "ISSUE_123",
+ "number": 5,
+ "url": "https://github.com/owner/repo/issues/5",
+ },
+ },
+ }),
+ ),
+ }
+
+ gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(matchers...))
+ deps := BaseDeps{GQLClient: gqlClient}
+ serverTool := GranularSetIssueFields(translations.NullTranslationHelper)
+ handler := serverTool.Handler(deps)
+
+ request := createMCPRequest(map[string]any{
+ "owner": "owner",
+ "repo": "repo",
+ "issue_number": float64(5),
+ "fields": []any{
+ map[string]any{
+ "field_id": "FIELD_1",
+ "text_value": "hello",
+ "confidence": "high",
+ },
+ },
+ })
+ result, err := handler(ContextWithDeps(context.Background(), deps), &request)
+ require.NoError(t, err)
+ assert.False(t, result.IsError)
+ })
+
+ t.Run("invalid confidence value returns error", func(t *testing.T) {
+ deps := BaseDeps{}
+ serverTool := GranularSetIssueFields(translations.NullTranslationHelper)
+ handler := serverTool.Handler(deps)
+
+ request := createMCPRequest(map[string]any{
+ "owner": "owner",
+ "repo": "repo",
+ "issue_number": float64(5),
+ "fields": []any{
+ map[string]any{
+ "field_id": "FIELD_1",
+ "text_value": "hello",
+ "confidence": "very_high",
+ },
+ },
+ })
+ result, err := handler(ContextWithDeps(context.Background(), deps), &request)
+ require.NoError(t, err)
+ textContent := getTextResult(t, result)
+ assert.Contains(t, textContent.Text, "confidence must be one of: low, medium, high")
+ })
+
+ t.Run("confidence is sent when supplied", func(t *testing.T) {
+ confidence := "high"
+ matchers := []githubv4mock.Matcher{
+ githubv4mock.NewQueryMatcher(
+ struct {
+ Repository struct {
+ Issue struct {
+ ID githubv4.ID
+ } `graphql:"issue(number: $issueNumber)"`
+ } `graphql:"repository(owner: $owner, name: $repo)"`
+ }{},
+ map[string]any{
+ "owner": githubv4.String("owner"),
+ "repo": githubv4.String("repo"),
+ "issueNumber": githubv4.Int(5),
+ },
+ githubv4mock.DataResponse(map[string]any{
+ "repository": map[string]any{
+ "issue": map[string]any{"id": "ISSUE_123"},
+ },
+ }),
+ ),
+ githubv4mock.NewMutationMatcher(
+ struct {
+ SetIssueFieldValue struct {
+ Issue struct {
+ ID githubv4.ID
+ Number githubv4.Int
+ URL githubv4.String
+ }
+ IssueFieldValues []struct {
+ TextValue struct {
+ Value string
+ } `graphql:"... on IssueFieldTextValue"`
+ SingleSelectValue struct {
+ Name string
+ } `graphql:"... on IssueFieldSingleSelectValue"`
+ DateValue struct {
+ Value string
+ } `graphql:"... on IssueFieldDateValue"`
+ NumberValue struct {
+ Value float64
+ } `graphql:"... on IssueFieldNumberValue"`
+ }
+ } `graphql:"setIssueFieldValue(input: $input)"`
+ }{},
+ SetIssueFieldValueInput{
+ IssueID: githubv4.ID("ISSUE_123"),
+ IssueFields: []IssueFieldCreateOrUpdateInput{
+ {
+ FieldID: githubv4.ID("FIELD_1"),
+ TextValue: githubv4.NewString(githubv4.String("hello")),
+ Confidence: &confidence,
+ },
+ },
+ },
+ nil,
+ githubv4mock.DataResponse(map[string]any{
+ "setIssueFieldValue": map[string]any{
+ "issue": map[string]any{
+ "id": "ISSUE_123",
+ "number": 5,
+ "url": "https://github.com/owner/repo/issues/5",
+ },
+ },
+ }),
+ ),
+ }
+
+ gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(matchers...))
+ deps := BaseDeps{GQLClient: gqlClient}
+ serverTool := GranularSetIssueFields(translations.NullTranslationHelper)
+ handler := serverTool.Handler(deps)
+
+ request := createMCPRequest(map[string]any{
+ "owner": "owner",
+ "repo": "repo",
+ "issue_number": float64(5),
+ "fields": []any{
+ map[string]any{
+ "field_id": "FIELD_1",
+ "text_value": "hello",
+ "confidence": "high",
+ },
+ },
+ })
+ result, err := handler(ContextWithDeps(context.Background(), deps), &request)
+ require.NoError(t, err)
+ assert.False(t, result.IsError, getTextResult(t, result).Text)
+ })
+
t.Run("successful set with suggest flag", func(t *testing.T) {
suggestTrue := githubv4.Boolean(true)
matchers := []githubv4mock.Matcher{
@@ -1478,4 +1895,101 @@ func TestGranularSetIssueFields(t *testing.T) {
require.NoError(t, err)
assert.False(t, result.IsError)
})
+
+ t.Run("sends GraphQL-Features: update_issue_suggestions header on mutation", func(t *testing.T) {
+ matchers := []githubv4mock.Matcher{
+ githubv4mock.NewQueryMatcher(
+ struct {
+ Repository struct {
+ Issue struct {
+ ID githubv4.ID
+ } `graphql:"issue(number: $issueNumber)"`
+ } `graphql:"repository(owner: $owner, name: $repo)"`
+ }{},
+ map[string]any{
+ "owner": githubv4.String("owner"),
+ "repo": githubv4.String("repo"),
+ "issueNumber": githubv4.Int(5),
+ },
+ githubv4mock.DataResponse(map[string]any{
+ "repository": map[string]any{
+ "issue": map[string]any{"id": "ISSUE_123"},
+ },
+ }),
+ ),
+ githubv4mock.NewMutationMatcher(
+ struct {
+ SetIssueFieldValue struct {
+ Issue struct {
+ ID githubv4.ID
+ Number githubv4.Int
+ URL githubv4.String
+ }
+ IssueFieldValues []struct {
+ TextValue struct {
+ Value string
+ } `graphql:"... on IssueFieldTextValue"`
+ SingleSelectValue struct {
+ Name string
+ } `graphql:"... on IssueFieldSingleSelectValue"`
+ DateValue struct {
+ Value string
+ } `graphql:"... on IssueFieldDateValue"`
+ NumberValue struct {
+ Value float64
+ } `graphql:"... on IssueFieldNumberValue"`
+ }
+ } `graphql:"setIssueFieldValue(input: $input)"`
+ }{},
+ SetIssueFieldValueInput{
+ IssueID: githubv4.ID("ISSUE_123"),
+ IssueFields: []IssueFieldCreateOrUpdateInput{
+ {
+ FieldID: githubv4.ID("FIELD_1"),
+ TextValue: githubv4.NewString(githubv4.String("hello")),
+ },
+ },
+ },
+ nil,
+ githubv4mock.DataResponse(map[string]any{
+ "setIssueFieldValue": map[string]any{
+ "issue": map[string]any{
+ "id": "ISSUE_123",
+ "number": 5,
+ "url": "https://github.com/owner/repo/issues/5",
+ },
+ },
+ }),
+ ),
+ }
+
+ // Build a transport chain matching production: GraphQLFeaturesTransport
+ // wraps a header-capturing spy, which forwards to the mock's RoundTripper.
+ // This verifies the mutation request sets the update_issue_suggestions
+ // feature flag so the rationale/suggest input fields are accepted.
+ mockClient := githubv4mock.NewMockedHTTPClient(matchers...)
+ spy := &headerCaptureTransport{inner: mockClient.Transport}
+ httpClient := &http.Client{
+ Transport: &transportpkg.GraphQLFeaturesTransport{Transport: spy},
+ }
+ gqlClient := githubv4.NewClient(httpClient)
+ deps := BaseDeps{GQLClient: gqlClient}
+ serverTool := GranularSetIssueFields(translations.NullTranslationHelper)
+ handler := serverTool.Handler(deps)
+
+ request := createMCPRequest(map[string]any{
+ "owner": "owner",
+ "repo": "repo",
+ "issue_number": float64(5),
+ "fields": []any{
+ map[string]any{"field_id": "FIELD_1", "text_value": "hello"},
+ },
+ })
+ result, err := handler(ContextWithDeps(context.Background(), deps), &request)
+ require.NoError(t, err)
+ require.False(t, result.IsError, getTextResult(t, result).Text)
+ // The last request captured is the mutation; the preceding issue ID
+ // query does not require the feature flag.
+ assert.Equal(t, "update_issue_suggestions", spy.captured.Get(headers.GraphQLFeaturesHeader))
+ })
}
diff --git a/pkg/github/helper_test.go b/pkg/github/helper_test.go
index fdac78ce3f..7f86c8b989 100644
--- a/pkg/github/helper_test.go
+++ b/pkg/github/helper_test.go
@@ -69,6 +69,7 @@ const (
// Pull request endpoints
GetReposPullsByOwnerByRepo = "GET /repos/{owner}/{repo}/pulls"
GetReposPullsByOwnerByRepoByPullNumber = "GET /repos/{owner}/{repo}/pulls/{pull_number}"
+ GetReposPullsCommitsByOwnerByRepoByPullNumber = "GET /repos/{owner}/{repo}/pulls/{pull_number}/commits"
GetReposPullsFilesByOwnerByRepoByPullNumber = "GET /repos/{owner}/{repo}/pulls/{pull_number}/files"
GetReposPullsReviewsByOwnerByRepoByPullNumber = "GET /repos/{owner}/{repo}/pulls/{pull_number}/reviews"
PostReposPullsByOwnerByRepo = "POST /repos/{owner}/{repo}/pulls"
diff --git a/pkg/github/ifc_labels.go b/pkg/github/ifc_labels.go
new file mode 100644
index 0000000000..a1c6fea367
--- /dev/null
+++ b/pkg/github/ifc_labels.go
@@ -0,0 +1,154 @@
+package github
+
+import (
+ "context"
+
+ "github.com/github/github-mcp-server/pkg/ifc"
+ "github.com/google/go-github/v87/github"
+ "github.com/modelcontextprotocol/go-sdk/mcp"
+)
+
+// setIFCLabel writes the given IFC security label into a tool result's _meta
+// under the "ifc" key, allocating the Meta map if necessary.
+func setIFCLabel(r *mcp.CallToolResult, label ifc.SecurityLabel) {
+ if r.Meta == nil {
+ r.Meta = mcp.Meta{}
+ }
+ r.Meta["ifc"] = label
+}
+
+// attachStaticIFCLabel attaches a fixed IFC label to a successful tool result
+// when IFC labels are enabled. It is used by tools whose label does not depend
+// on any repository visibility lookup (e.g. security alerts, global
+// advisories, team membership, notification subjects).
+//
+// Error results are left untouched, and the label is omitted entirely when the
+// IFC feature flag is disabled.
+func attachStaticIFCLabel(ctx context.Context, deps ToolDependencies, r *mcp.CallToolResult, label ifc.SecurityLabel) *mcp.CallToolResult {
+ if r == nil || r.IsError || !deps.IsFeatureEnabled(ctx, FeatureFlagIFCLabels) {
+ return r
+ }
+ setIFCLabel(r, label)
+ return r
+}
+
+// attachRepoVisibilityIFCLabel attaches an IFC label derived from a single
+// repository's visibility to a successful tool result when IFC labels are
+// enabled. The concrete label is produced by labelFn, which receives whether
+// the repository is private.
+//
+// The repository visibility is resolved via FetchRepoIsPrivate. Consistent
+// with the other IFC-labeled tools, if the visibility lookup fails the label
+// is omitted rather than risking a misclassification. Error results and the
+// disabled-feature case are left untouched.
+func attachRepoVisibilityIFCLabel(
+ ctx context.Context,
+ deps ToolDependencies,
+ client *github.Client,
+ owner, repo string,
+ r *mcp.CallToolResult,
+ labelFn func(isPrivate bool) ifc.SecurityLabel,
+) *mcp.CallToolResult {
+ if r == nil || r.IsError || !deps.IsFeatureEnabled(ctx, FeatureFlagIFCLabels) {
+ return r
+ }
+ isPrivate, err := FetchRepoIsPrivate(ctx, client, owner, repo)
+ if err != nil {
+ return r
+ }
+ setIFCLabel(r, labelFn(isPrivate))
+ return r
+}
+
+// ifcSearchPostProcessOption returns a searchOption that attaches IFC labels to
+// a multi-repository search result. The feature-flag check is centralized here
+// (mirroring the attach* helpers above) rather than in each search tool
+// handler: when IFC labels are disabled it returns a no-op option, so callers
+// can pass it unconditionally to searchHandler.
+func ifcSearchPostProcessOption(ctx context.Context, deps ToolDependencies) searchOption {
+ if !deps.IsFeatureEnabled(ctx, FeatureFlagIFCLabels) {
+ return func(*searchConfig) {}
+ }
+ return withSearchPostProcess(searchIssuesIFCPostProcess(deps))
+}
+
+// attachRepoVisibilityIFCLabelLazy is like attachRepoVisibilityIFCLabel but
+// resolves the REST client itself, only when IFC labels are enabled. It is used
+// by tools whose handler holds a GraphQL client (or no client yet) and would
+// otherwise have to acquire a REST client solely to compute the label. The
+// feature-flag check is centralized here so callers can invoke it
+// unconditionally; if the client cannot be obtained or the visibility lookup
+// fails, the label is omitted rather than risking a misclassification.
+func attachRepoVisibilityIFCLabelLazy(
+ ctx context.Context,
+ deps ToolDependencies,
+ owner, repo string,
+ r *mcp.CallToolResult,
+ labelFn func(isPrivate bool) ifc.SecurityLabel,
+) *mcp.CallToolResult {
+ if r == nil || r.IsError || !deps.IsFeatureEnabled(ctx, FeatureFlagIFCLabels) {
+ return r
+ }
+ client, err := deps.GetClient(ctx)
+ if err != nil {
+ return r
+ }
+ return attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, r, labelFn)
+}
+
+// attachJoinedIFCLabel attaches an IFC label computed by joining a set of
+// per-item visibilities (true == private for repositories, true == public for
+// gists) when IFC labels are enabled. joinFn is the lattice join for the
+// relevant item kind (e.g. ifc.LabelSearchIssues or ifc.LabelGistList). The
+// visibility slice is cheap to build from an already-fetched response, so
+// callers may construct it unconditionally and let this helper own the
+// feature-flag gate.
+func attachJoinedIFCLabel(
+ ctx context.Context,
+ deps ToolDependencies,
+ r *mcp.CallToolResult,
+ visibilities []bool,
+ joinFn func([]bool) ifc.SecurityLabel,
+) *mcp.CallToolResult {
+ if r == nil || r.IsError || !deps.IsFeatureEnabled(ctx, FeatureFlagIFCLabels) {
+ return r
+ }
+ setIFCLabel(r, joinFn(visibilities))
+ return r
+}
+
+// newRepoVisibilityIFCLabeler returns a closure that attaches a repo-visibility
+// IFC label to a tool result, for handlers that have several return paths and
+// want to label each one. The returned function owns the feature-flag gate (so
+// callers invoke it unconditionally) and caches the repository visibility
+// lookup across calls, so a handler that returns from many branches only pays
+// for one FetchRepoIsPrivate call. A failed visibility lookup is not cached, so
+// a later return path can retry; on persistent failure the label is omitted
+// rather than risking a misclassification.
+func newRepoVisibilityIFCLabeler(
+ ctx context.Context,
+ deps ToolDependencies,
+ client *github.Client,
+ owner, repo string,
+ labelFn func(isPrivate bool) ifc.SecurityLabel,
+) func(*mcp.CallToolResult) *mcp.CallToolResult {
+ var (
+ known bool
+ isPrivate bool
+ )
+ return func(r *mcp.CallToolResult) *mcp.CallToolResult {
+ if r == nil || r.IsError || !deps.IsFeatureEnabled(ctx, FeatureFlagIFCLabels) {
+ return r
+ }
+ if !known {
+ p, err := FetchRepoIsPrivate(ctx, client, owner, repo)
+ if err != nil {
+ return r
+ }
+ isPrivate = p
+ known = true
+ }
+ setIFCLabel(r, labelFn(isPrivate))
+ return r
+ }
+}
diff --git a/pkg/github/issue_fields.go b/pkg/github/issue_fields.go
index 1eabbc02f3..27a4a09c5d 100644
--- a/pkg/github/issue_fields.go
+++ b/pkg/github/issue_fields.go
@@ -8,6 +8,7 @@ import (
ghcontext "github.com/github/github-mcp-server/pkg/context"
ghErrors "github.com/github/github-mcp-server/pkg/errors"
+ "github.com/github/github-mcp-server/pkg/ifc"
"github.com/github/github-mcp-server/pkg/inventory"
"github.com/github/github-mcp-server/pkg/scopes"
"github.com/github/github-mcp-server/pkg/translations"
@@ -155,7 +156,17 @@ func ListIssueFields(t translations.TranslationHelperFunc) inventory.ServerTool
return utils.NewToolResultErrorFromErr("failed to marshal issue fields", err), nil, nil
}
- return utils.NewToolResultText(string(r)), nil, nil
+ result := utils.NewToolResultText(string(r))
+ // Issue field definitions are repo/org structural metadata
+ // (trusted). When scoped to a specific repo, confidentiality
+ // follows that repo's visibility; for an org-level lookup (no
+ // repo) it is conservatively treated as private.
+ if repo == "" {
+ result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelRepoMetadata(true))
+ } else {
+ result = attachRepoVisibilityIFCLabelLazy(ctx, deps, owner, repo, result, ifc.LabelRepoMetadata)
+ }
+ return result, nil, nil
})
st.FeatureFlagEnable = FeatureFlagIssueFields
return st
diff --git a/pkg/github/issues.go b/pkg/github/issues.go
index 0469789812..27fc0a4abe 100644
--- a/pkg/github/issues.go
+++ b/pkg/github/issues.go
@@ -804,20 +804,7 @@ Options are:
// attachIFC adds the IFC label to a successful tool result when
// IFC labels are enabled. If the visibility lookup fails the
// label is omitted rather than misclassifying the result.
- attachIFC := func(r *mcp.CallToolResult) *mcp.CallToolResult {
- if r == nil || r.IsError || !deps.IsFeatureEnabled(ctx, FeatureFlagIFCLabels) {
- return r
- }
- isPrivate, err := FetchRepoIsPrivate(ctx, client, owner, repo)
- if err != nil {
- return r
- }
- if r.Meta == nil {
- r.Meta = mcp.Meta{}
- }
- r.Meta["ifc"] = ifc.LabelListIssues(isPrivate)
- return r
- }
+ attachIFC := newRepoVisibilityIFCLabeler(ctx, deps, client, owner, repo, ifc.LabelListIssues)
switch method {
case "get":
@@ -1132,7 +1119,13 @@ func ListIssueTypes(t translations.TranslationHelperFunc) inventory.ServerTool {
return utils.NewToolResultErrorFromErr("failed to marshal issue types", err), nil, nil
}
- return utils.NewToolResultText(string(r)), nil, nil
+ result := utils.NewToolResultText(string(r))
+ // Issue types are org-defined structural metadata (trusted, not
+ // attacker-authored). They are scoped to an organization rather
+ // than a single repo, so confidentiality is conservatively treated
+ // as private (restricted to org members).
+ result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelRepoMetadata(true))
+ return result, nil, nil
})
}
@@ -1144,7 +1137,7 @@ func AddIssueComment(t translations.TranslationHelperFunc) inventory.ServerTool
Name: "add_issue_comment",
Description: t("TOOL_ADD_ISSUE_COMMENT_DESCRIPTION", "Add a comment to a specific issue in a GitHub repository. Use this tool to add comments to pull requests as well (in this case pass pull request number as issue_number), but only if user is not asking specifically to add review comments."),
Annotations: &mcp.ToolAnnotations{
- Title: t("TOOL_ADD_ISSUE_COMMENT_USER_TITLE", "Add comment to issue"),
+ Title: t("TOOL_ADD_ISSUE_COMMENT_USER_TITLE", "Add comment to issue or pull request"),
ReadOnlyHint: false,
},
InputSchema: &jsonschema.Schema{
@@ -1511,11 +1504,7 @@ func SearchIssues(t translations.TranslationHelperFunc) inventory.ServerTool {
},
[]scopes.Scope{scopes.Repo},
func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
- var options []searchOption
- if deps.IsFeatureEnabled(ctx, FeatureFlagIFCLabels) {
- options = append(options, withSearchPostProcess(searchIssuesIFCPostProcess(deps)))
- }
- result, err := searchIssuesHandler(ctx, deps, args, options...)
+ result, err := searchIssuesHandler(ctx, deps, args, ifcSearchPostProcessOption(ctx, deps))
return result, nil, err
})
}
@@ -1763,6 +1752,36 @@ func searchIssuesHandler(ctx context.Context, deps ToolDependencies, args map[st
// IssueWriteUIResourceURI is the URI for the issue_write tool's MCP App UI resource.
const IssueWriteUIResourceURI = "ui://github-mcp-server/issue-write"
+// issueWriteFormParams are the parameters the issue_write MCP App form collects
+// and re-sends on submit. The form only supports title/body editing (plus the
+// routing/identity fields), so any other parameter present on a call cannot be
+// represented by the form.
+var issueWriteFormParams = map[string]struct{}{
+ "method": {},
+ "owner": {},
+ "repo": {},
+ "title": {},
+ "body": {},
+ "issue_number": {},
+ "_ui_submitted": {},
+}
+
+// issueWriteHasNonFormParams reports whether the call carries any parameter the
+// issue_write MCP App form cannot represent (anything outside issueWriteFormParams,
+// e.g. labels, assignees, issue_fields or a state change). Such calls must bypass
+// the UI form and execute directly so the supplied values aren't silently dropped.
+func issueWriteHasNonFormParams(args map[string]any) bool {
+ for key, value := range args {
+ if value == nil {
+ continue
+ }
+ if _, ok := issueWriteFormParams[key]; !ok {
+ return true
+ }
+ }
+ return false
+}
+
// IssueWrite is the FeatureFlagIssueFields-enabled variant of issue_write
// (with the issue_fields parameter). LegacyIssueWrite is served when the flag
// is off. Both register under the tool name "issue_write"; exactly one is
@@ -1776,7 +1795,7 @@ func IssueWrite(t translations.TranslationHelperFunc) inventory.ServerTool {
Name: "issue_write",
Description: t("TOOL_ISSUE_WRITE_DESCRIPTION", "Create a new or update an existing issue in a GitHub repository."),
Annotations: &mcp.ToolAnnotations{
- Title: t("TOOL_ISSUE_WRITE_USER_TITLE", "Create or update issue"),
+ Title: t("TOOL_ISSUE_WRITE_USER_TITLE", "Create or update issue/pull request"),
ReadOnlyHint: false,
},
Meta: mcp.Meta{
@@ -1908,26 +1927,22 @@ Options are:
return utils.NewToolResultError(err.Error()), nil, nil
}
- // When MCP Apps are enabled and the client supports UI,
- // check if this is a UI form submission. The UI sends _ui_submitted=true
- // to distinguish form submissions from LLM calls.
+ // When MCP Apps are enabled and the client supports UI, route the
+ // call to the interactive form unless it is itself a form submission
+ // (the UI sends _ui_submitted=true) or it carries parameters the form
+ // cannot represent (e.g. labels, assignees or issue_fields). Those
+ // must be applied directly so their values aren't silently dropped.
uiSubmitted, _ := OptionalParam[bool](args, "_ui_submitted")
- if deps.IsFeatureEnabled(ctx, MCPAppsFeatureFlag) && clientSupportsUI(ctx, req) && !uiSubmitted {
+ if deps.IsFeatureEnabled(ctx, MCPAppsFeatureFlag) && clientSupportsUI(ctx, req) && !uiSubmitted && !issueWriteHasNonFormParams(args) {
if method == "update" {
- // Skip the UI form when a state change is requested because
- // the form only handles title/body editing and would lose the
- // state transition (e.g. closing or reopening the issue).
- if _, hasState := args["state"]; !hasState {
- issueNumber, numErr := RequiredInt(args, "issue_number")
- if numErr != nil {
- return utils.NewToolResultError("issue_number is required for update method"), nil, nil
- }
- return utils.NewToolResultText(fmt.Sprintf("Ready to update issue #%d in %s/%s. IMPORTANT: The issue has NOT been updated yet. Do NOT tell the user the issue was updated. The user MUST click Submit in the form to update it.", issueNumber, owner, repo)), nil, nil
+ issueNumber, numErr := RequiredInt(args, "issue_number")
+ if numErr != nil {
+ return utils.NewToolResultError("issue_number is required for update method"), nil, nil
}
- } else {
- return utils.NewToolResultText(fmt.Sprintf("Ready to create an issue in %s/%s. IMPORTANT: The issue has NOT been created yet. Do NOT tell the user the issue was created. The user MUST click Submit in the form to create it.", owner, repo)), nil, nil
+ return utils.NewToolResultText(fmt.Sprintf("Ready to update issue #%d in %s/%s. IMPORTANT: The issue has NOT been updated yet. Do NOT tell the user the issue was updated. The user MUST click Submit in the form to update it.", issueNumber, owner, repo)), nil, nil
}
+ return utils.NewToolResultText(fmt.Sprintf("Ready to create an issue in %s/%s. IMPORTANT: The issue has NOT been created yet. Do NOT tell the user the issue was created. The user MUST click Submit in the form to create it.", owner, repo)), nil, nil
}
title, err := OptionalParam[string](args, "title")
@@ -1946,12 +1961,16 @@ Options are:
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
+ assigneesValue, assigneesProvided := args["assignees"]
+ assigneesProvided = assigneesProvided && assigneesValue != nil
// Get labels
labels, err := OptionalStringArrayParam(args, "labels")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
+ labelsValue, labelsProvided := args["labels"]
+ labelsProvided = labelsProvided && labelsValue != nil
// Get optional milestone
milestone, err := OptionalIntParam(args, "milestone")
@@ -2023,7 +2042,10 @@ Options are:
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
- result, err := UpdateIssue(ctx, client, gqlClient, owner, repo, issueNumber, title, body, assignees, labels, milestoneNum, issueType, issueFieldValues, fieldIDsToDelete, state, stateReason, duplicateOf)
+ result, err := UpdateIssue(ctx, client, gqlClient, owner, repo, issueNumber, title, body, assignees, labels, milestoneNum, issueType, issueFieldValues, fieldIDsToDelete, state, stateReason, duplicateOf, UpdateIssueOptions{
+ AssigneesProvided: assigneesProvided,
+ LabelsProvided: labelsProvided,
+ })
return result, nil, err
default:
return utils.NewToolResultError("invalid method, must be either 'create' or 'update'"), nil, nil
@@ -2047,7 +2069,7 @@ func LegacyIssueWrite(t translations.TranslationHelperFunc) inventory.ServerTool
Name: "issue_write",
Description: t("TOOL_ISSUE_WRITE_DESCRIPTION", "Create a new or update an existing issue in a GitHub repository."),
Annotations: &mcp.ToolAnnotations{
- Title: t("TOOL_ISSUE_WRITE_USER_TITLE", "Create or update issue"),
+ Title: t("TOOL_ISSUE_WRITE_USER_TITLE", "Create or update issue/pull request"),
ReadOnlyHint: false,
},
Meta: mcp.Meta{
@@ -2144,26 +2166,22 @@ Options are:
return utils.NewToolResultError(err.Error()), nil, nil
}
- // When MCP Apps are enabled and the client supports UI,
- // check if this is a UI form submission. The UI sends _ui_submitted=true
- // to distinguish form submissions from LLM calls.
+ // When MCP Apps are enabled and the client supports UI, route the
+ // call to the interactive form unless it is itself a form submission
+ // (the UI sends _ui_submitted=true) or it carries parameters the form
+ // cannot represent (e.g. labels, assignees or issue_fields). Those
+ // must be applied directly so their values aren't silently dropped.
uiSubmitted, _ := OptionalParam[bool](args, "_ui_submitted")
- if deps.IsFeatureEnabled(ctx, MCPAppsFeatureFlag) && clientSupportsUI(ctx, req) && !uiSubmitted {
+ if deps.IsFeatureEnabled(ctx, MCPAppsFeatureFlag) && clientSupportsUI(ctx, req) && !uiSubmitted && !issueWriteHasNonFormParams(args) {
if method == "update" {
- // Skip the UI form when a state change is requested because
- // the form only handles title/body editing and would lose the
- // state transition (e.g. closing or reopening the issue).
- if _, hasState := args["state"]; !hasState {
- issueNumber, numErr := RequiredInt(args, "issue_number")
- if numErr != nil {
- return utils.NewToolResultError("issue_number is required for update method"), nil, nil
- }
- return utils.NewToolResultText(fmt.Sprintf("Ready to update issue #%d in %s/%s. IMPORTANT: The issue has NOT been updated yet. Do NOT tell the user the issue was updated. The user MUST click Submit in the form to update it.", issueNumber, owner, repo)), nil, nil
+ issueNumber, numErr := RequiredInt(args, "issue_number")
+ if numErr != nil {
+ return utils.NewToolResultError("issue_number is required for update method"), nil, nil
}
- } else {
- return utils.NewToolResultText(fmt.Sprintf("Ready to create an issue in %s/%s. IMPORTANT: The issue has NOT been created yet. Do NOT tell the user the issue was created. The user MUST click Submit in the form to create it.", owner, repo)), nil, nil
+ return utils.NewToolResultText(fmt.Sprintf("Ready to update issue #%d in %s/%s. IMPORTANT: The issue has NOT been updated yet. Do NOT tell the user the issue was updated. The user MUST click Submit in the form to update it.", issueNumber, owner, repo)), nil, nil
}
+ return utils.NewToolResultText(fmt.Sprintf("Ready to create an issue in %s/%s. IMPORTANT: The issue has NOT been created yet. Do NOT tell the user the issue was created. The user MUST click Submit in the form to create it.", owner, repo)), nil, nil
}
title, err := OptionalParam[string](args, "title")
@@ -2182,12 +2200,16 @@ Options are:
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
+ assigneesValue, assigneesProvided := args["assignees"]
+ assigneesProvided = assigneesProvided && assigneesValue != nil
// Get labels
labels, err := OptionalStringArrayParam(args, "labels")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
+ labelsValue, labelsProvided := args["labels"]
+ labelsProvided = labelsProvided && labelsValue != nil
// Get optional milestone
milestone, err := OptionalIntParam(args, "milestone")
@@ -2244,7 +2266,10 @@ Options are:
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
- result, err := UpdateIssue(ctx, client, gqlClient, owner, repo, issueNumber, title, body, assignees, labels, milestoneNum, issueType, nil, nil, state, stateReason, duplicateOf)
+ result, err := UpdateIssue(ctx, client, gqlClient, owner, repo, issueNumber, title, body, assignees, labels, milestoneNum, issueType, nil, nil, state, stateReason, duplicateOf, UpdateIssueOptions{
+ AssigneesProvided: assigneesProvided,
+ LabelsProvided: labelsProvided,
+ })
return result, nil, err
default:
return utils.NewToolResultError("invalid method, must be either 'create' or 'update'"), nil, nil
@@ -2308,7 +2333,24 @@ func CreateIssue(ctx context.Context, client *github.Client, owner string, repo
return utils.NewToolResultText(string(r)), nil
}
-func UpdateIssue(ctx context.Context, client *github.Client, gqlClient *githubv4.Client, owner string, repo string, issueNumber int, title string, body string, assignees []string, labels []string, milestoneNum int, issueType string, issueFieldValues []*github.IssueRequestFieldValue, fieldIDsToDelete []int64, state string, stateReason string, duplicateOf int) (*mcp.CallToolResult, error) {
+// UpdateIssueOptions controls which optional fields are included in an issue update request.
+type UpdateIssueOptions struct {
+ // AssigneesProvided sends the assignees field even when the slice is empty.
+ AssigneesProvided bool
+ // LabelsProvided sends the labels field even when the slice is empty.
+ LabelsProvided bool
+}
+
+func UpdateIssue(ctx context.Context, client *github.Client, gqlClient *githubv4.Client, owner string, repo string, issueNumber int, title string, body string, assignees []string, labels []string, milestoneNum int, issueType string, issueFieldValues []*github.IssueRequestFieldValue, fieldIDsToDelete []int64, state string, stateReason string, duplicateOf int, opts ...UpdateIssueOptions) (*mcp.CallToolResult, error) {
+ updateOptions := UpdateIssueOptions{
+ AssigneesProvided: len(assignees) > 0,
+ LabelsProvided: len(labels) > 0,
+ }
+ for _, opt := range opts {
+ updateOptions.AssigneesProvided = updateOptions.AssigneesProvided || opt.AssigneesProvided
+ updateOptions.LabelsProvided = updateOptions.LabelsProvided || opt.LabelsProvided
+ }
+
// Create the issue request with only provided fields
issueRequest := &github.IssueRequest{}
@@ -2321,11 +2363,11 @@ func UpdateIssue(ctx context.Context, client *github.Client, gqlClient *githubv4
issueRequest.Body = github.Ptr(body)
}
- if len(labels) > 0 {
+ if updateOptions.LabelsProvided {
issueRequest.Labels = &labels
}
- if len(assignees) > 0 {
+ if updateOptions.AssigneesProvided {
issueRequest.Assignees = &assignees
}
@@ -2716,12 +2758,7 @@ func ListIssues(t translations.TranslationHelperFunc) inventory.ServerTool {
}
result := MarshalledTextResult(resp)
- if deps.IsFeatureEnabled(ctx, FeatureFlagIFCLabels) {
- if result.Meta == nil {
- result.Meta = mcp.Meta{}
- }
- result.Meta["ifc"] = ifc.LabelListIssues(isPrivate)
- }
+ result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelListIssues(isPrivate))
return result, nil, nil
})
st.FeatureFlagEnable = FeatureFlagIssueFields
@@ -2919,12 +2956,7 @@ func LegacyListIssues(t translations.TranslationHelperFunc) inventory.ServerTool
}
result := MarshalledTextResult(resp)
- if deps.IsFeatureEnabled(ctx, FeatureFlagIFCLabels) {
- if result.Meta == nil {
- result.Meta = mcp.Meta{}
- }
- result.Meta["ifc"] = ifc.LabelListIssues(isPrivate)
- }
+ result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelListIssues(isPrivate))
return result, nil, nil
})
st.FeatureFlagDisable = []string{FeatureFlagIssueFields}
diff --git a/pkg/github/issues_granular.go b/pkg/github/issues_granular.go
index 73fa75413c..3ddfd682f6 100644
--- a/pkg/github/issues_granular.go
+++ b/pkg/github/issues_granular.go
@@ -7,6 +7,7 @@ import (
"maps"
"strings"
+ ghcontext "github.com/github/github-mcp-server/pkg/context"
ghErrors "github.com/github/github-mcp-server/pkg/errors"
"github.com/github/github-mcp-server/pkg/inventory"
"github.com/github/github-mcp-server/pkg/scopes"
@@ -258,17 +259,18 @@ func GranularUpdateIssueAssignees(t translations.TranslationHelperFunc) inventor
)
}
-// labelWithRationale represents the object form of a label entry, allowing a
-// rationale and/or suggest flag to be sent alongside the label name.
-type labelWithRationale struct {
- Name string `json:"name"`
- Rationale string `json:"rationale,omitempty"`
- Suggest bool `json:"suggest,omitempty"`
+// labelWithIntent represents the object form of a label entry, allowing a
+// rationale, confidence level, and/or suggest flag to be sent alongside the label name.
+type labelWithIntent struct {
+ Name string `json:"name"`
+ Rationale string `json:"rationale,omitempty"`
+ Confidence string `json:"confidence,omitempty"`
+ Suggest bool `json:"suggest,omitempty"`
}
// labelsUpdateRequest is a custom request body for updating an issue's labels
// where individual labels may optionally include a rationale. Each element of
-// Labels is either a string (label name) or a labelWithRationale object.
+// Labels is either a string (label name) or a labelWithIntent object.
type labelsUpdateRequest struct {
Labels []any `json:"labels"`
}
@@ -279,7 +281,7 @@ func GranularUpdateIssueLabels(t translations.TranslationHelperFunc) inventory.S
ToolsetMetadataIssues,
mcp.Tool{
Name: "update_issue_labels",
- Description: t("TOOL_UPDATE_ISSUE_LABELS_DESCRIPTION", "Update the labels of an existing issue. This replaces the current labels with the provided list."),
+ Description: t("TOOL_UPDATE_ISSUE_LABELS_DESCRIPTION", "Update the labels of an existing issue. This replaces the current labels with the provided list. When setting values, include a confidence level (low, medium, or high) reflecting how certain you are about the choice."),
Annotations: &mcp.ToolAnnotations{
Title: t("TOOL_UPDATE_ISSUE_LABELS_USER_TITLE", "Update Issue Labels"),
ReadOnlyHint: false,
@@ -321,6 +323,11 @@ func GranularUpdateIssueLabels(t translations.TranslationHelperFunc) inventory.S
"State the concrete signal (e.g. 'Reports a crash when saving' → bug).",
MaxLength: jsonschema.Ptr(280),
},
+ "confidence": {
+ Type: "string",
+ Description: "How confident you are in this choice. Use 'high' for clear signal or explicit user request, 'medium' for reasonable inference with some ambiguity, 'low' for best guess with limited signal.",
+ Enum: []any{"low", "medium", "high"},
+ },
"is_suggestion": {
Type: "boolean",
Description: "If true, this label is sent to the API as a suggestion (suggest:true) rather than an applied label. " +
@@ -387,18 +394,25 @@ func GranularUpdateIssueLabels(t translations.TranslationHelperFunc) inventory.S
if len([]rune(rationale)) > 280 {
return utils.NewToolResultError("label rationale must be 280 characters or less"), nil, nil
}
+ confidence, err := OptionalParam[string](v, "confidence")
+ if err != nil {
+ return utils.NewToolResultError(err.Error()), nil, nil
+ }
+ if confidence != "" && confidence != "low" && confidence != "medium" && confidence != "high" {
+ return utils.NewToolResultError("confidence must be one of: low, medium, high"), nil, nil
+ }
isSuggestion, err := OptionalParam[bool](v, "is_suggestion")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
- if rationale == "" && !isSuggestion {
+ if rationale == "" && !isSuggestion && confidence == "" {
payload = append(payload, name)
} else {
useObjectForm = true
- payload = append(payload, labelWithRationale{Name: name, Rationale: rationale, Suggest: isSuggestion})
+ payload = append(payload, labelWithIntent{Name: name, Rationale: rationale, Confidence: confidence, Suggest: isSuggestion})
}
default:
- return utils.NewToolResultError("each label must be a string or an object with 'name' and optional 'rationale' and/or 'is_suggestion'"), nil, nil
+ return utils.NewToolResultError("each label must be a string or an object with 'name' and optional 'rationale', 'confidence', and/or 'is_suggestion'"), nil, nil
}
}
@@ -470,18 +484,19 @@ func GranularUpdateIssueMilestone(t translations.TranslationHelperFunc) inventor
)
}
-// issueTypeWithRationale represents the object form of the issue type field,
-// allowing a rationale and/or suggest flag to be sent alongside the type name.
-type issueTypeWithRationale struct {
- Value string `json:"value"`
- Rationale string `json:"rationale,omitempty"`
- Suggest bool `json:"suggest,omitempty"`
+// issueTypeWithIntent represents the object form of the issue type field,
+// allowing a rationale, confidence level, and/or suggest flag to be sent alongside the type name.
+type issueTypeWithIntent struct {
+ Value string `json:"value"`
+ Rationale string `json:"rationale,omitempty"`
+ Confidence string `json:"confidence,omitempty"`
+ Suggest bool `json:"suggest,omitempty"`
}
// issueTypeUpdateRequest is a custom request body for updating an issue type
-// with an optional rationale, using the object form that the REST API accepts.
+// with optional intent metadata, using the object form that the REST API accepts.
type issueTypeUpdateRequest struct {
- Type issueTypeWithRationale `json:"type"`
+ Type issueTypeWithIntent `json:"type"`
}
// GranularUpdateIssueType creates a tool to update an issue's type.
@@ -490,7 +505,7 @@ func GranularUpdateIssueType(t translations.TranslationHelperFunc) inventory.Ser
ToolsetMetadataIssues,
mcp.Tool{
Name: "update_issue_type",
- Description: t("TOOL_UPDATE_ISSUE_TYPE_DESCRIPTION", "Update the type of an existing issue (e.g. 'bug', 'feature')."),
+ Description: t("TOOL_UPDATE_ISSUE_TYPE_DESCRIPTION", "Update the type of an existing issue (e.g. 'bug', 'feature'). When setting values, include a confidence level (low, medium, or high) reflecting how certain you are about the choice."),
Annotations: &mcp.ToolAnnotations{
Title: t("TOOL_UPDATE_ISSUE_TYPE_USER_TITLE", "Update Issue Type"),
ReadOnlyHint: false,
@@ -523,6 +538,11 @@ func GranularUpdateIssueType(t translations.TranslationHelperFunc) inventory.Ser
"State the concrete signal (e.g. 'Reports a crash when saving' → bug, 'Asks for dark mode support' → feature).",
MaxLength: jsonschema.Ptr(280),
},
+ "confidence": {
+ Type: "string",
+ Description: "How confident you are in this choice. Use 'high' for clear signal or explicit user request, 'medium' for reasonable inference with some ambiguity, 'low' for best guess with limited signal.",
+ Enum: []any{"low", "medium", "high"},
+ },
"is_suggestion": {
Type: "boolean",
Description: "If true, this issue type change is sent to the API as a suggestion (suggest:true) rather than an applied value. " +
@@ -558,6 +578,13 @@ func GranularUpdateIssueType(t translations.TranslationHelperFunc) inventory.Ser
if len([]rune(rationale)) > 280 {
return utils.NewToolResultError("parameter rationale must be 280 characters or less"), nil, nil
}
+ confidence, err := OptionalParam[string](args, "confidence")
+ if err != nil {
+ return utils.NewToolResultError(err.Error()), nil, nil
+ }
+ if confidence != "" && confidence != "low" && confidence != "medium" && confidence != "high" {
+ return utils.NewToolResultError("confidence must be one of: low, medium, high"), nil, nil
+ }
isSuggestion, err := OptionalParam[bool](args, "is_suggestion")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
@@ -569,12 +596,13 @@ func GranularUpdateIssueType(t translations.TranslationHelperFunc) inventory.Ser
}
var body any
- if rationale != "" || isSuggestion {
+ if rationale != "" || isSuggestion || confidence != "" {
body = &issueTypeUpdateRequest{
- Type: issueTypeWithRationale{
- Value: issueType,
- Rationale: rationale,
- Suggest: isSuggestion,
+ Type: issueTypeWithIntent{
+ Value: issueType,
+ Rationale: rationale,
+ Confidence: confidence,
+ Suggest: isSuggestion,
},
}
} else {
@@ -887,6 +915,7 @@ type IssueFieldCreateOrUpdateInput struct {
SingleSelectOptionID *githubv4.ID `json:"singleSelectOptionId,omitempty"`
Delete *githubv4.Boolean `json:"delete,omitempty"`
Rationale *githubv4.String `json:"rationale,omitempty"`
+ Confidence *string `json:"confidence,omitempty"`
Suggest *githubv4.Boolean `json:"suggest,omitempty"`
}
@@ -956,6 +985,11 @@ func GranularSetIssueFields(t translations.TranslationHelperFunc) inventory.Serv
"State the concrete signal (e.g. 'Reports a crash when saving' → high priority).",
MaxLength: jsonschema.Ptr(280),
},
+ "confidence": {
+ Type: "string",
+ Description: "How confident you are in this choice. Use 'high' for clear signal or explicit user request, 'medium' for reasonable inference with some ambiguity, 'low' for best guess with limited signal.",
+ Enum: []any{"low", "medium", "high"},
+ },
"is_suggestion": {
Type: "boolean",
Description: "If true, this field value is sent to the API as a suggestion (suggest:true) rather than an applied value. " +
@@ -1073,6 +1107,17 @@ func GranularSetIssueFields(t translations.TranslationHelperFunc) inventory.Serv
}
}
+ confidence, err := OptionalParam[string](fieldMap, "confidence")
+ if err != nil {
+ return utils.NewToolResultError(err.Error()), nil, nil
+ }
+ if confidence != "" && confidence != "low" && confidence != "medium" && confidence != "high" {
+ return utils.NewToolResultError("confidence must be one of: low, medium, high"), nil, nil
+ }
+ if confidence != "" {
+ input.Confidence = &confidence
+ }
+
isSuggestion, err := OptionalParam[bool](fieldMap, "is_suggestion")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
@@ -1126,7 +1171,10 @@ func GranularSetIssueFields(t translations.TranslationHelperFunc) inventory.Serv
IssueFields: issueFields,
}
- if err := gqlClient.Mutate(ctx, &mutation, mutationInput, nil); err != nil {
+ // The rationale and suggest input fields on IssueFieldCreateOrUpdateInput
+ // are gated behind the update_issue_suggestions GraphQL feature flag.
+ ctxWithFeatures := ghcontext.WithGraphQLFeatures(ctx, "update_issue_suggestions")
+ if err := gqlClient.Mutate(ctxWithFeatures, &mutation, mutationInput, nil); err != nil {
return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to set issue field values", err), nil, nil
}
diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go
index b04370976e..7e47cdb527 100644
--- a/pkg/github/issues_test.go
+++ b/pkg/github/issues_test.go
@@ -1700,6 +1700,129 @@ func Test_IssueWrite_MCPAppsFeature_UIGate(t *testing.T) {
assert.Contains(t, textContent.Text, "Ready to update issue #1",
"update without state should show UI form")
})
+
+ t.Run("UI client with issue_fields skips form and executes directly", func(t *testing.T) {
+ // The MCP App form does not collect or re-send issue_fields, so a call
+ // carrying them must bypass the form and apply the values directly.
+ fieldsClient := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
+ PostReposIssuesByOwnerByRepo: expectRequestBody(t, map[string]any{
+ "title": "Issue with fields",
+ "body": "",
+ "labels": []any{},
+ "assignees": []any{},
+ "issue_field_values": []any{
+ map[string]any{"field_id": float64(101), "value": "P1"},
+ },
+ }).andThen(
+ mockResponse(t, http.StatusCreated, &github.Issue{
+ Number: github.Ptr(125),
+ Title: github.Ptr("Issue with fields"),
+ HTMLURL: github.Ptr("https://github.com/owner/repo/issues/125"),
+ State: github.Ptr("open"),
+ }),
+ ),
+ }))
+ fieldsGQLClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(
+ githubv4mock.NewQueryMatcher(
+ issueFieldWriteMetadataQuery{},
+ map[string]any{
+ "owner": githubv4.String("owner"),
+ "repo": githubv4.String("repo"),
+ },
+ githubv4mock.DataResponse(map[string]any{
+ "repository": map[string]any{
+ "issueFields": map[string]any{
+ "nodes": []any{
+ map[string]any{
+ "__typename": "IssueFieldSingleSelect",
+ "fullDatabaseId": "101",
+ "name": "Priority",
+ "dataType": "single_select",
+ "options": []any{
+ map[string]any{"fullDatabaseId": "9001", "name": "P1"},
+ },
+ },
+ },
+ },
+ },
+ }),
+ ),
+ ))
+
+ fieldsDeps := BaseDeps{
+ Client: fieldsClient,
+ GQLClient: fieldsGQLClient,
+ featureChecker: featureCheckerFor(MCPAppsFeatureFlag),
+ }
+ fieldsHandler := serverTool.Handler(fieldsDeps)
+
+ request := createMCPRequestWithSession(t, ClientNameVSCodeInsiders, true, map[string]any{
+ "method": "create",
+ "owner": "owner",
+ "repo": "repo",
+ "title": "Issue with fields",
+ "issue_fields": []any{
+ map[string]any{"field_name": "Priority", "field_option_name": "P1"},
+ },
+ })
+ result, err := fieldsHandler(ContextWithDeps(context.Background(), fieldsDeps), &request)
+ require.NoError(t, err)
+
+ textContent := getTextResult(t, result)
+ assert.NotContains(t, textContent.Text, "Ready to create an issue",
+ "issue_fields should skip UI form")
+ assert.Contains(t, textContent.Text, "https://github.com/owner/repo/issues/125",
+ "issue_fields call should execute directly and return issue URL")
+ })
+
+ t.Run("UI client with labels skips form and executes directly", func(t *testing.T) {
+ // The form does not collect labels, so a call carrying them must bypass
+ // the form rather than silently drop them.
+ request := createMCPRequestWithSession(t, ClientNameVSCodeInsiders, true, map[string]any{
+ "method": "create",
+ "owner": "owner",
+ "repo": "repo",
+ "title": "Test",
+ "labels": []any{"bug"},
+ })
+ result, err := handler(ContextWithDeps(context.Background(), deps), &request)
+ require.NoError(t, err)
+
+ textContent := getTextResult(t, result)
+ assert.NotContains(t, textContent.Text, "Ready to create an issue",
+ "labels should skip UI form")
+ assert.Contains(t, textContent.Text, "https://github.com/owner/repo/issues/1",
+ "labels call should execute directly and return issue URL")
+ })
+}
+
+func Test_issueWriteHasNonFormParams(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ args map[string]any
+ want bool
+ }{
+ {name: "no params", args: map[string]any{}, want: false},
+ {name: "only form params", args: map[string]any{"method": "create", "owner": "o", "repo": "r", "title": "t", "body": "b", "issue_number": float64(1), "_ui_submitted": true}, want: false},
+ {name: "labels present", args: map[string]any{"title": "t", "labels": []any{"bug"}}, want: true},
+ {name: "assignees present", args: map[string]any{"title": "t", "assignees": []any{"octocat"}}, want: true},
+ {name: "milestone present", args: map[string]any{"title": "t", "milestone": float64(2)}, want: true},
+ {name: "type present", args: map[string]any{"title": "t", "type": "Bug"}, want: true},
+ {name: "issue_fields present", args: map[string]any{"issue_fields": []any{map[string]any{"field_name": "Priority"}}}, want: true},
+ {name: "state present", args: map[string]any{"state": "closed"}, want: true},
+ {name: "state_reason present", args: map[string]any{"state_reason": "completed"}, want: true},
+ {name: "duplicate_of present", args: map[string]any{"duplicate_of": float64(7)}, want: true},
+ {name: "nil value is ignored", args: map[string]any{"issue_fields": nil}, want: false},
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+ assert.Equal(t, tc.want, issueWriteHasNonFormParams(tc.args))
+ })
+ }
}
func Test_ListIssues(t *testing.T) {
@@ -2864,6 +2987,33 @@ func Test_UpdateIssue(t *testing.T) {
expectError: false,
expectedIssue: mockUpdatedIssue,
},
+ {
+ name: "partial update clears labels and assignees",
+ mockedRESTClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
+ PatchReposIssuesByOwnerByRepoByIssueNumber: expectRequestBody(t, map[string]any{
+ "labels": []any{},
+ "assignees": []any{},
+ }).andThen(
+ mockResponse(t, http.StatusOK, &github.Issue{
+ Number: github.Ptr(123),
+ HTMLURL: github.Ptr("https://github.com/owner/repo/issues/123"),
+ }),
+ ),
+ }),
+ mockedGQLClient: githubv4mock.NewMockedHTTPClient(),
+ requestArgs: map[string]any{
+ "method": "update",
+ "owner": "owner",
+ "repo": "repo",
+ "issue_number": float64(123),
+ "labels": []any{},
+ "assignees": []any{},
+ },
+ expectError: false,
+ expectedIssue: &github.Issue{
+ HTMLURL: github.Ptr("https://github.com/owner/repo/issues/123"),
+ },
+ },
{
name: "partial update with issue fields reconciled by names",
mockedRESTClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
@@ -3283,6 +3433,47 @@ func Test_UpdateIssue(t *testing.T) {
}
}
+func Test_LegacyUpdateIssueClearsLabelsAndAssignees(t *testing.T) {
+ serverTool := LegacyIssueWrite(translations.NullTranslationHelper)
+ updatedIssue := &github.Issue{
+ Number: github.Ptr(8),
+ HTMLURL: github.Ptr("https://github.com/owner/repo/issues/8"),
+ }
+
+ client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
+ PatchReposIssuesByOwnerByRepoByIssueNumber: expectRequestBody(t, map[string]any{
+ "labels": []any{},
+ "assignees": []any{},
+ }).andThen(mockResponse(t, http.StatusOK, updatedIssue)),
+ }))
+ gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient())
+ deps := BaseDeps{
+ Client: client,
+ GQLClient: gqlClient,
+ }
+ handler := serverTool.Handler(deps)
+
+ request := createMCPRequest(map[string]any{
+ "method": "update",
+ "owner": "owner",
+ "repo": "repo",
+ "issue_number": float64(8),
+ "labels": []any{},
+ "assignees": []any{},
+ })
+ result, err := handler(ContextWithDeps(context.Background(), deps), &request)
+
+ require.NoError(t, err)
+ if result.IsError {
+ t.Fatalf("Unexpected error result: %s", getErrorResult(t, result).Text)
+ }
+ textContent := getTextResult(t, result)
+
+ var updateResp MinimalResponse
+ require.NoError(t, json.Unmarshal([]byte(textContent.Text), &updateResp))
+ assert.Equal(t, updatedIssue.GetHTMLURL(), updateResp.URL)
+}
+
func Test_ParseISOTimestamp(t *testing.T) {
tests := []struct {
name string
diff --git a/pkg/github/labels.go b/pkg/github/labels.go
index e8d8102cbf..0e49968496 100644
--- a/pkg/github/labels.go
+++ b/pkg/github/labels.go
@@ -7,6 +7,7 @@ import (
"strings"
ghErrors "github.com/github/github-mcp-server/pkg/errors"
+ "github.com/github/github-mcp-server/pkg/ifc"
"github.com/github/github-mcp-server/pkg/inventory"
"github.com/github/github-mcp-server/pkg/scopes"
"github.com/github/github-mcp-server/pkg/translations"
@@ -105,7 +106,11 @@ func GetLabel(t translations.TranslationHelperFunc) inventory.ServerTool {
return nil, nil, fmt.Errorf("failed to marshal label: %w", err)
}
- return utils.NewToolResultText(string(out)), nil, nil
+ result := utils.NewToolResultText(string(out))
+ // Labels are structural repo metadata defined by collaborators
+ // (trusted); confidentiality follows repo visibility.
+ result = attachRepoVisibilityIFCLabelLazy(ctx, deps, owner, repo, result, ifc.LabelRepoMetadata)
+ return result, nil, nil
},
)
}
@@ -204,7 +209,11 @@ func ListLabels(t translations.TranslationHelperFunc) inventory.ServerTool {
return nil, nil, fmt.Errorf("failed to marshal labels: %w", err)
}
- return utils.NewToolResultText(string(out)), nil, nil
+ result := utils.NewToolResultText(string(out))
+ // Labels are structural repo metadata defined by collaborators
+ // (trusted); confidentiality follows repo visibility.
+ result = attachRepoVisibilityIFCLabelLazy(ctx, deps, owner, repo, result, ifc.LabelRepoMetadata)
+ return result, nil, nil
},
)
}
diff --git a/pkg/github/minimal_types.go b/pkg/github/minimal_types.go
index ff4149a225..eff6edc133 100644
--- a/pkg/github/minimal_types.go
+++ b/pkg/github/minimal_types.go
@@ -108,6 +108,7 @@ type MinimalCommitFile struct {
Additions int `json:"additions,omitempty"`
Deletions int `json:"deletions,omitempty"`
Changes int `json:"changes,omitempty"`
+ Patch string `json:"patch,omitempty"`
}
// MinimalPRFile represents a file changed in a pull request.
@@ -122,6 +123,14 @@ type MinimalPRFile struct {
PreviousFilename string `json:"previous_filename,omitempty"`
}
+// MinimalPullRequestCommit is the trimmed output type for commits listed on a pull request.
+type MinimalPullRequestCommit struct {
+ SHA string `json:"sha"`
+ HTMLURL string `json:"html_url,omitempty"`
+ Message string `json:"message,omitempty"`
+ Author *MinimalCommitAuthor `json:"author,omitempty"`
+}
+
// MinimalCommit is the trimmed output type for commit objects.
type MinimalCommit struct {
SHA string `json:"sha"`
@@ -1463,8 +1472,34 @@ func newMinimalCommitFromCore(sha, htmlURL string, commit *github.Commit, author
return minimalCommit
}
-// convertToMinimalCommit converts a GitHub API RepositoryCommit to MinimalCommit
-func convertToMinimalCommit(commit *github.RepositoryCommit, includeDiffs bool) MinimalCommit {
+// commitDetail controls how much per-file information convertToMinimalCommit
+// includes in its output.
+type commitDetail string
+
+const (
+ // commitDetailNone omits Stats and Files entirely.
+ commitDetailNone commitDetail = "none"
+ // commitDetailStats includes Stats and Files with metadata only
+ // (filename, status, additions, deletions, changes) but no patch text.
+ commitDetailStats commitDetail = "stats"
+ // commitDetailFullPatch additionally includes the unified diff for each file.
+ commitDetailFullPatch commitDetail = "full_patch"
+)
+
+// parseCommitDetail validates the user-supplied detail value and returns the
+// default (stats) when the value is empty.
+func parseCommitDetail(s string) (commitDetail, error) {
+ switch s {
+ case "":
+ return commitDetailStats, nil
+ case string(commitDetailNone), string(commitDetailStats), string(commitDetailFullPatch):
+ return commitDetail(s), nil
+ default:
+ return "", fmt.Errorf("invalid detail %q: must be one of \"none\", \"stats\", \"full_patch\"", s)
+ }
+}
+
+func convertToMinimalCommit(commit *github.RepositoryCommit, detail commitDetail) MinimalCommit {
minimalCommit := newMinimalCommitFromCore(
commit.GetSHA(),
commit.GetHTMLURL(),
@@ -1473,28 +1508,32 @@ func convertToMinimalCommit(commit *github.RepositoryCommit, includeDiffs bool)
commit.Committer,
)
- // Only include stats and files if includeDiffs is true
- if includeDiffs {
- if commit.Stats != nil {
- minimalCommit.Stats = &MinimalCommitStats{
- Additions: commit.Stats.GetAdditions(),
- Deletions: commit.Stats.GetDeletions(),
- Total: commit.Stats.GetTotal(),
- }
+ if detail == commitDetailNone {
+ return minimalCommit
+ }
+
+ if commit.Stats != nil {
+ minimalCommit.Stats = &MinimalCommitStats{
+ Additions: commit.Stats.GetAdditions(),
+ Deletions: commit.Stats.GetDeletions(),
+ Total: commit.Stats.GetTotal(),
}
+ }
- if len(commit.Files) > 0 {
- minimalCommit.Files = make([]MinimalCommitFile, 0, len(commit.Files))
- for _, file := range commit.Files {
- minimalFile := MinimalCommitFile{
- Filename: file.GetFilename(),
- Status: file.GetStatus(),
- Additions: file.GetAdditions(),
- Deletions: file.GetDeletions(),
- Changes: file.GetChanges(),
- }
- minimalCommit.Files = append(minimalCommit.Files, minimalFile)
+ if len(commit.Files) > 0 {
+ minimalCommit.Files = make([]MinimalCommitFile, 0, len(commit.Files))
+ for _, file := range commit.Files {
+ minimalFile := MinimalCommitFile{
+ Filename: file.GetFilename(),
+ Status: file.GetStatus(),
+ Additions: file.GetAdditions(),
+ Deletions: file.GetDeletions(),
+ Changes: file.GetChanges(),
}
+ if detail == commitDetailFullPatch {
+ minimalFile.Patch = file.GetPatch()
+ }
+ minimalCommit.Files = append(minimalCommit.Files, minimalFile)
}
}
@@ -1547,7 +1586,7 @@ type MinimalReviewComment struct {
// MinimalReviewThread is the trimmed output type for PR review thread objects.
type MinimalReviewThread struct {
- ID string
+ ID string `json:"id"`
IsResolved bool `json:"is_resolved"`
IsOutdated bool `json:"is_outdated"`
IsCollapsed bool `json:"is_collapsed"`
@@ -1578,6 +1617,44 @@ func convertToMinimalPRFiles(files []*github.CommitFile) []MinimalPRFile {
return result
}
+func convertToMinimalPullRequestCommits(commits []*github.RepositoryCommit) []MinimalPullRequestCommit {
+ result := make([]MinimalPullRequestCommit, 0, len(commits))
+ for _, commit := range commits {
+ if commit == nil {
+ continue
+ }
+
+ minimalCommit := MinimalPullRequestCommit{
+ SHA: commit.GetSHA(),
+ HTMLURL: commit.GetHTMLURL(),
+ }
+
+ if commit.Commit != nil {
+ minimalCommit.Message = commit.Commit.GetMessage()
+ minimalCommit.Author = convertToMinimalCommitAuthor(commit.Commit.Author)
+ }
+
+ result = append(result, minimalCommit)
+ }
+ return result
+}
+
+func convertToMinimalCommitAuthor(author *github.CommitAuthor) *MinimalCommitAuthor {
+ if author == nil {
+ return nil
+ }
+
+ minimalAuthor := &MinimalCommitAuthor{
+ Name: author.GetName(),
+ Email: author.GetEmail(),
+ }
+ if author.Date != nil {
+ minimalAuthor.Date = author.Date.Format(time.RFC3339)
+ }
+
+ return minimalAuthor
+}
+
// convertToMinimalBranch converts a GitHub API Branch to MinimalBranch
func convertToMinimalBranch(branch *github.Branch) MinimalBranch {
return MinimalBranch{
diff --git a/pkg/github/notifications.go b/pkg/github/notifications.go
index 61d8f40b2e..1504757a7f 100644
--- a/pkg/github/notifications.go
+++ b/pkg/github/notifications.go
@@ -9,6 +9,7 @@ import (
"time"
ghErrors "github.com/github/github-mcp-server/pkg/errors"
+ "github.com/github/github-mcp-server/pkg/ifc"
"github.com/github/github-mcp-server/pkg/inventory"
"github.com/github/github-mcp-server/pkg/scopes"
"github.com/github/github-mcp-server/pkg/translations"
@@ -386,7 +387,13 @@ func GetNotificationDetails(t translations.TranslationHelperFunc) inventory.Serv
return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil
}
- return utils.NewToolResultText(string(r)), nil, nil
+ result := utils.NewToolResultText(string(r))
+ // A notification subject points at an issue, PR, comment, or
+ // discussion whose content is user-authored (untrusted). It is
+ // delivered to a specific recipient and may reference private
+ // repositories, so confidentiality is private.
+ result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelNotificationDetails())
+ return result, nil, nil
},
)
}
diff --git a/pkg/github/params.go b/pkg/github/params.go
index ecdc8c3549..a6b43375ef 100644
--- a/pkg/github/params.go
+++ b/pkg/github/params.go
@@ -376,7 +376,7 @@ func WithCursorPagination(schema *jsonschema.Schema) *jsonschema.Schema {
schema.Properties["after"] = &jsonschema.Schema{
Type: "string",
- Description: "Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs.",
+ Description: "Cursor for pagination. Use the cursor from the previous response.",
}
return schema
@@ -435,6 +435,22 @@ type CursorPaginationParams struct {
After string
}
+type pageInfo struct {
+ HasNextPage bool `json:"hasNextPage"`
+ HasPreviousPage bool `json:"hasPreviousPage"`
+ NextCursor string `json:"nextCursor,omitempty"`
+ PrevCursor string `json:"prevCursor,omitempty"`
+}
+
+func buildPageInfo(resp *github.Response) pageInfo {
+ return pageInfo{
+ HasNextPage: resp.After != "",
+ HasPreviousPage: resp.Before != "",
+ NextCursor: resp.After,
+ PrevCursor: resp.Before,
+ }
+}
+
// ToGraphQLParams converts cursor pagination parameters to GraphQL-specific parameters.
func (p CursorPaginationParams) ToGraphQLParams() (*GraphQLPaginationParams, error) {
if p.PerPage > 100 {
diff --git a/pkg/github/projects.go b/pkg/github/projects.go
index 7c383c1111..85774490de 100644
--- a/pkg/github/projects.go
+++ b/pkg/github/projects.go
@@ -6,9 +6,11 @@ import (
"fmt"
"io"
"net/http"
+ "strconv"
"time"
ghErrors "github.com/github/github-mcp-server/pkg/errors"
+ "github.com/github/github-mcp-server/pkg/ifc"
"github.com/github/github-mcp-server/pkg/inventory"
"github.com/github/github-mcp-server/pkg/scopes"
"github.com/github/github-mcp-server/pkg/translations"
@@ -45,6 +47,8 @@ const (
projectsMethodListProjectStatusUpdates = "list_project_status_updates"
projectsMethodGetProjectStatusUpdate = "get_project_status_update"
projectsMethodCreateProjectStatusUpdate = "create_project_status_update"
+ projectsMethodCreateProject = "create_project"
+ projectsMethodCreateIterationField = "create_iteration_field"
)
// GraphQL types for ProjectV2 status updates
@@ -224,9 +228,19 @@ Use this tool to list projects for a user or organization, or list project field
return utils.NewToolResultError(err.Error()), nil, nil
}
+ // attachIFC adds the IFC label to a successful result when IFC
+ // labels are enabled. Project titles, item content, field
+ // definitions, and status updates are user-authored free text
+ // (untrusted); confidentiality is conservatively private since the
+ // project's public flag is not available across every sub-result.
+ attachIFC := func(r *mcp.CallToolResult) *mcp.CallToolResult {
+ return attachStaticIFCLabel(ctx, deps, r, ifc.LabelProject(false))
+ }
+
switch method {
case projectsMethodListProjects:
- return listProjects(ctx, client, args, owner, ownerType)
+ result, payload, err := listProjects(ctx, client, args, owner, ownerType)
+ return attachIFC(result), payload, err
default:
// All other methods require project_number and ownerType detection
if ownerType == "" {
@@ -242,15 +256,18 @@ Use this tool to list projects for a user or organization, or list project field
switch method {
case projectsMethodListProjectFields:
- return listProjectFields(ctx, client, args, owner, ownerType)
+ result, payload, err := listProjectFields(ctx, client, args, owner, ownerType)
+ return attachIFC(result), payload, err
case projectsMethodListProjectItems:
- return listProjectItems(ctx, client, args, owner, ownerType)
+ result, payload, err := listProjectItems(ctx, client, args, owner, ownerType)
+ return attachIFC(result), payload, err
case projectsMethodListProjectStatusUpdates:
gqlClient, err := deps.GetGQLClient(ctx)
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
- return listProjectStatusUpdates(ctx, gqlClient, args, owner, ownerType)
+ result, payload, err := listProjectStatusUpdates(ctx, gqlClient, args, owner, ownerType)
+ return attachIFC(result), payload, err
default:
return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil
}
@@ -329,6 +346,14 @@ Use this tool to get details about individual projects, project fields, and proj
return utils.NewToolResultError(err.Error()), nil, nil
}
+ // attachIFC adds the IFC label to a successful result when IFC
+ // labels are enabled. Project data is user-authored free text
+ // (untrusted); confidentiality is conservatively private since the
+ // project's public flag is not available across every sub-result.
+ attachIFC := func(r *mcp.CallToolResult) *mcp.CallToolResult {
+ return attachStaticIFCLabel(ctx, deps, r, ifc.LabelProject(false))
+ }
+
// Handle get_project_status_update early — it only needs status_update_id
if method == projectsMethodGetProjectStatusUpdate {
statusUpdateID, err := RequiredParam[string](args, "status_update_id")
@@ -339,7 +364,8 @@ Use this tool to get details about individual projects, project fields, and proj
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
- return getProjectStatusUpdate(ctx, gqlClient, statusUpdateID)
+ result, payload, err := getProjectStatusUpdate(ctx, gqlClient, statusUpdateID)
+ return attachIFC(result), payload, err
}
owner, err := RequiredParam[string](args, "owner")
@@ -372,13 +398,15 @@ Use this tool to get details about individual projects, project fields, and proj
switch method {
case projectsMethodGetProject:
- return getProject(ctx, client, owner, ownerType, projectNumber)
+ result, payload, err := getProject(ctx, client, owner, ownerType, projectNumber)
+ return attachIFC(result), payload, err
case projectsMethodGetProjectField:
fieldID, err := RequiredBigInt(args, "field_id")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
- return getProjectField(ctx, client, owner, ownerType, projectNumber, fieldID)
+ result, payload, err := getProjectField(ctx, client, owner, ownerType, projectNumber, fieldID)
+ return attachIFC(result), payload, err
case projectsMethodGetProjectItem:
itemID, err := RequiredBigInt(args, "item_id")
if err != nil {
@@ -388,7 +416,8 @@ Use this tool to get details about individual projects, project fields, and proj
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
- return getProjectItem(ctx, client, owner, ownerType, projectNumber, itemID, fields)
+ result, payload, err := getProjectItem(ctx, client, owner, ownerType, projectNumber, itemID, fields)
+ return attachIFC(result), payload, err
default:
return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil
}
@@ -403,9 +432,9 @@ func ProjectsWrite(t translations.TranslationHelperFunc) inventory.ServerTool {
ToolsetMetadataProjects,
mcp.Tool{
Name: "projects_write",
- Description: t("TOOL_PROJECTS_WRITE_DESCRIPTION", "Add, update, or delete project items, or create status updates in a GitHub Project."),
+ Description: t("TOOL_PROJECTS_WRITE_DESCRIPTION", "Create and manage GitHub Projects: create projects, add/update/delete items, create status updates, and add iteration fields."),
Annotations: &mcp.ToolAnnotations{
- Title: t("TOOL_PROJECTS_WRITE_USER_TITLE", "Modify GitHub Project items"),
+ Title: t("TOOL_PROJECTS_WRITE_USER_TITLE", "Manage GitHub Projects"),
ReadOnlyHint: false,
DestructiveHint: jsonschema.Ptr(true),
},
@@ -420,11 +449,13 @@ func ProjectsWrite(t translations.TranslationHelperFunc) inventory.ServerTool {
projectsMethodUpdateProjectItem,
projectsMethodDeleteProjectItem,
projectsMethodCreateProjectStatusUpdate,
+ projectsMethodCreateProject,
+ projectsMethodCreateIterationField,
},
},
"owner_type": {
Type: "string",
- Description: "Owner type (user or org). If not provided, will be automatically detected.",
+ Description: "Owner type (user or org). Required for 'create_project' method. If not provided for other methods, will be automatically detected.",
Enum: []any{"user", "org"},
},
"owner": {
@@ -433,7 +464,11 @@ func ProjectsWrite(t translations.TranslationHelperFunc) inventory.ServerTool {
},
"project_number": {
Type: "number",
- Description: "The project's number.",
+ Description: "The project's number. Required for all methods except 'create_project'.",
+ },
+ "title": {
+ Type: "string",
+ Description: "The project title. Required for 'create_project' method.",
},
"item_id": {
Type: "number",
@@ -475,14 +510,45 @@ func ProjectsWrite(t translations.TranslationHelperFunc) inventory.ServerTool {
},
"start_date": {
Type: "string",
- Description: "The start date of the status update in YYYY-MM-DD format. Used for 'create_project_status_update' method.",
+ Description: "Start date in YYYY-MM-DD format. Used for 'create_project_status_update' and 'create_iteration_field' methods.",
},
"target_date": {
Type: "string",
Description: "The target date of the status update in YYYY-MM-DD format. Used for 'create_project_status_update' method.",
},
+ "field_name": {
+ Type: "string",
+ Description: "The name of the iteration field (e.g. 'Sprint'). Required for 'create_iteration_field' method.",
+ },
+ "iteration_duration": {
+ Type: "number",
+ Description: "Duration in days for iterations of the field (e.g. 7 for weekly, 14 for bi-weekly). Required for 'create_iteration_field' method.",
+ },
+ "iterations": {
+ Type: "array",
+ 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: &jsonschema.Schema{
+ Type: "object",
+ AdditionalProperties: &jsonschema.Schema{Not: &jsonschema.Schema{}},
+ Properties: map[string]*jsonschema.Schema{
+ "title": {
+ Type: "string",
+ Description: "Iteration title (e.g. 'Sprint 1')",
+ },
+ "start_date": {
+ Type: "string",
+ Description: "Start date in YYYY-MM-DD format",
+ },
+ "duration": {
+ Type: "number",
+ Description: "Duration in days",
+ },
+ },
+ Required: []string{"title", "start_date", "duration"},
+ },
+ },
},
- Required: []string{"method", "owner", "project_number"},
+ Required: []string{"method", "owner"},
},
},
[]scopes.Scope{scopes.Project},
@@ -502,17 +568,22 @@ func ProjectsWrite(t translations.TranslationHelperFunc) inventory.ServerTool {
return utils.NewToolResultError(err.Error()), nil, nil
}
- projectNumber, err := RequiredInt(args, "project_number")
+ gqlClient, err := deps.GetGQLClient(ctx)
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
- client, err := deps.GetClient(ctx)
+ // create_project does not require project_number or a REST client
+ if method == projectsMethodCreateProject {
+ return createProject(ctx, gqlClient, owner, ownerType, args)
+ }
+
+ projectNumber, err := RequiredInt(args, "project_number")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
- gqlClient, err := deps.GetGQLClient(ctx)
+ client, err := deps.GetClient(ctx)
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
@@ -595,6 +666,8 @@ func ProjectsWrite(t translations.TranslationHelperFunc) inventory.ServerTool {
return utils.NewToolResultError(err.Error()), nil, nil
}
return createProjectStatusUpdate(ctx, gqlClient, owner, ownerType, projectNumber, body, status, startDate, targetDate)
+ case projectsMethodCreateIterationField:
+ return createIterationField(ctx, gqlClient, owner, ownerType, projectNumber, args)
default:
return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil
}
@@ -1079,7 +1152,8 @@ func addProjectItem(ctx context.Context, gqlClient *githubv4.Client, owner, owne
var mutation struct {
AddProjectV2ItemByID struct {
Item struct {
- ID githubv4.ID
+ ID githubv4.ID
+ FullDatabaseID string `graphql:"fullDatabaseId"`
}
} `graphql:"addProjectV2ItemById(input: $input)"`
}
@@ -1105,6 +1179,12 @@ func addProjectItem(ctx context.Context, gqlClient *githubv4.Client, owner, owne
"id": mutation.AddProjectV2ItemByID.Item.ID,
"message": fmt.Sprintf("Successfully added %s %s/%s#%d to project %s/%d", itemType, itemOwner, itemRepo, itemNumber, owner, projectNumber),
}
+ if fullDatabaseID := mutation.AddProjectV2ItemByID.Item.FullDatabaseID; fullDatabaseID != "" {
+ result["full_database_id"] = fullDatabaseID
+ if itemID, err := strconv.ParseInt(fullDatabaseID, 10, 64); err == nil {
+ result["item_id"] = itemID
+ }
+ }
r, err := json.Marshal(result)
if err != nil {
@@ -1296,13 +1376,6 @@ func getProjectStatusUpdate(ctx context.Context, gqlClient *githubv4.Client, sta
return utils.NewToolResultText(string(r)), nil, nil
}
-type pageInfo struct {
- HasNextPage bool `json:"hasNextPage"`
- HasPreviousPage bool `json:"hasPreviousPage"`
- NextCursor string `json:"nextCursor,omitempty"`
- PrevCursor string `json:"prevCursor,omitempty"`
-}
-
// validateAndConvertToInt64 ensures the value is a number and converts it to int64.
func validateAndConvertToInt64(value any) (int64, error) {
switch v := value.(type) {
@@ -1353,15 +1426,6 @@ func buildUpdateProjectItem(input map[string]any) (*github.UpdateProjectItemOpti
return payload, nil
}
-func buildPageInfo(resp *github.Response) pageInfo {
- return pageInfo{
- HasNextPage: resp.After != "",
- HasPreviousPage: resp.Before != "",
- NextCursor: resp.After,
- PrevCursor: resp.Before,
- }
-}
-
func extractPaginationOptionsFromArgs(args map[string]any) (github.ListProjectsPaginationOptions, error) {
perPage, err := OptionalIntParamWithDefault(args, "per_page", MaxProjectsPerPage)
if err != nil {
@@ -1438,6 +1502,265 @@ func resolvePullRequestNodeID(ctx context.Context, gqlClient *githubv4.Client, o
return query.Repository.PullRequest.ID, nil
}
+// createProject handles the create_project method for ProjectsWrite.
+func createProject(ctx context.Context, gqlClient *githubv4.Client, owner, ownerType string, args map[string]any) (*mcp.CallToolResult, any, error) {
+ if ownerType == "" {
+ return utils.NewToolResultError("owner_type is required for create_project"), nil, nil
+ }
+ if ownerType != "user" && ownerType != "org" {
+ return utils.NewToolResultError(fmt.Sprintf("invalid owner_type %q: must be \"user\" or \"org\"", ownerType)), nil, nil
+ }
+
+ title, err := RequiredParam[string](args, "title")
+ if err != nil {
+ return utils.NewToolResultError(err.Error()), nil, nil
+ }
+
+ ownerID, err := getOwnerNodeID(ctx, gqlClient, owner, ownerType)
+ if err != nil {
+ return utils.NewToolResultError(fmt.Sprintf("failed to get owner ID: %v", err)), nil, nil
+ }
+
+ var mutation struct {
+ CreateProjectV2 struct {
+ ProjectV2 struct {
+ ID string
+ Number int
+ Title string
+ URL string
+ }
+ } `graphql:"createProjectV2(input: $input)"`
+ }
+
+ input := githubv4.CreateProjectV2Input{
+ OwnerID: githubv4.ID(ownerID),
+ Title: githubv4.String(title),
+ }
+
+ err = gqlClient.Mutate(ctx, &mutation, input, nil)
+ if err != nil {
+ return utils.NewToolResultError(fmt.Sprintf("failed to create project: %v", err)), nil, nil
+ }
+
+ result := struct {
+ ID string `json:"id"`
+ Number int `json:"number"`
+ Title string `json:"title"`
+ URL string `json:"url"`
+ }{
+ ID: mutation.CreateProjectV2.ProjectV2.ID,
+ Number: mutation.CreateProjectV2.ProjectV2.Number,
+ Title: mutation.CreateProjectV2.ProjectV2.Title,
+ URL: mutation.CreateProjectV2.ProjectV2.URL,
+ }
+
+ return MarshalledTextResult(result), nil, nil
+}
+
+// createIterationField handles the create_iteration_field method for ProjectsWrite.
+//
+// GitHub's GraphQL API requires two mutations to fully configure an iteration field:
+// 1. createProjectV2Field creates the field with DataType=ITERATION (no schedule yet).
+// 2. updateProjectV2Field sets the start date, duration, and optional named iterations.
+//
+// If step 2 fails, the field already exists with default settings and can be reconfigured
+// by calling this method again (the create will fail with a duplicate-name error, which
+// surfaces clearly) or by deleting the field via the GitHub UI.
+func createIterationField(ctx context.Context, gqlClient *githubv4.Client, owner, ownerType string, projectNumber int, args map[string]any) (*mcp.CallToolResult, any, error) {
+ fieldName, err := RequiredParam[string](args, "field_name")
+ if err != nil {
+ return utils.NewToolResultError(err.Error()), nil, nil
+ }
+ duration, err := RequiredInt(args, "iteration_duration")
+ if err != nil {
+ return utils.NewToolResultError(err.Error()), nil, nil
+ }
+ startDateStr, err := RequiredParam[string](args, "start_date")
+ if err != nil {
+ return utils.NewToolResultError(err.Error()), nil, nil
+ }
+
+ projectID, err := resolveProjectNodeID(ctx, gqlClient, owner, ownerType, projectNumber)
+ if err != nil {
+ return utils.NewToolResultError(fmt.Sprintf("failed to get project ID: %v", err)), nil, nil
+ }
+
+ // Step 1: Create the iteration field.
+ var createMutation struct {
+ CreateProjectV2Field struct {
+ ProjectV2Field struct {
+ ProjectV2IterationField struct {
+ ID string
+ Name string
+ } `graphql:"... on ProjectV2IterationField"`
+ } `graphql:"projectV2Field"`
+ } `graphql:"createProjectV2Field(input: $input)"`
+ }
+
+ createInput := githubv4.CreateProjectV2FieldInput{
+ ProjectID: githubv4.ID(projectID),
+ DataType: githubv4.ProjectV2CustomFieldType("ITERATION"),
+ Name: githubv4.String(fieldName),
+ }
+
+ err = gqlClient.Mutate(ctx, &createMutation, createInput, nil)
+ if err != nil {
+ return utils.NewToolResultError(fmt.Sprintf("failed to create iteration field: %v", err)), nil, nil
+ }
+
+ fieldID := createMutation.CreateProjectV2Field.ProjectV2Field.ProjectV2IterationField.ID
+
+ // Step 2: Configure the iteration field with start date and duration.
+ var updateMutation struct {
+ UpdateProjectV2Field struct {
+ ProjectV2Field struct {
+ ProjectV2IterationField struct {
+ ID string
+ Name string
+ Configuration struct {
+ Iterations []struct {
+ ID string
+ Title string
+ StartDate string
+ Duration int
+ }
+ }
+ } `graphql:"... on ProjectV2IterationField"`
+ } `graphql:"projectV2Field"`
+ } `graphql:"updateProjectV2Field(input: $input)"`
+ }
+
+ parsedStartDate, err := time.Parse("2006-01-02", startDateStr)
+ if err != nil {
+ return utils.NewToolResultError(fmt.Sprintf("failed to parse start_date %s: %v", startDateStr, err)), nil, nil
+ }
+
+ // GitHub's ProjectV2IterationFieldConfigurationInput requires `iterations` as a
+ // non-null array, so we always send at least an empty slice. When omitted, GitHub
+ // generates a default set of iterations from start_date and duration.
+ iterationsInput := []ProjectV2IterationFieldIterationInput{}
+
+ if rawIterations, ok := args["iterations"].([]any); ok && len(rawIterations) > 0 {
+ for i, item := range rawIterations {
+ iterMap, ok := item.(map[string]any)
+ if !ok {
+ return utils.NewToolResultError(fmt.Sprintf("iterations[%d] must be an object", i)), nil, nil
+ }
+ iterTitle, ok := iterMap["title"].(string)
+ if !ok || iterTitle == "" {
+ return utils.NewToolResultError(fmt.Sprintf("iterations[%d]: title is required and must be a non-empty string", i)), nil, nil
+ }
+ iterStartDate, ok := iterMap["start_date"].(string)
+ if !ok || iterStartDate == "" {
+ return utils.NewToolResultError(fmt.Sprintf("iterations[%d]: start_date is required and must be a non-empty string", i)), nil, nil
+ }
+ iterDuration, ok := iterMap["duration"].(float64)
+ if !ok || iterDuration <= 0 {
+ return utils.NewToolResultError(fmt.Sprintf("iterations[%d]: duration is required and must be a positive number", i)), nil, nil
+ }
+
+ parsedIterStartDate, err := time.Parse("2006-01-02", iterStartDate)
+ if err != nil {
+ return utils.NewToolResultError(fmt.Sprintf("iterations[%d]: failed to parse start_date %q: %v", i, iterStartDate, err)), nil, nil
+ }
+
+ iterationsInput = append(iterationsInput, ProjectV2IterationFieldIterationInput{
+ Title: githubv4.String(iterTitle),
+ StartDate: githubv4.Date{Time: parsedIterStartDate},
+ Duration: githubv4.Int(int32(iterDuration)), //nolint:gosec // Iteration durations are small day counts
+ })
+ }
+ }
+
+ configInput := ProjectV2IterationFieldConfigurationInput{
+ Duration: githubv4.Int(int32(duration)), //nolint:gosec // Iteration durations are small day counts
+ StartDate: githubv4.Date{Time: parsedStartDate},
+ Iterations: iterationsInput,
+ }
+
+ updateInput := UpdateProjectV2FieldInput{
+ FieldID: githubv4.ID(fieldID),
+ IterationConfiguration: &configInput,
+ }
+
+ err = gqlClient.Mutate(ctx, &updateMutation, updateInput, nil)
+ if err != nil {
+ return utils.NewToolResultError(fmt.Sprintf("failed to update iteration configuration: %v", err)), nil, nil
+ }
+
+ field := updateMutation.UpdateProjectV2Field.ProjectV2Field.ProjectV2IterationField
+ iterResults := make([]map[string]any, 0, len(field.Configuration.Iterations))
+ for _, iter := range field.Configuration.Iterations {
+ iterResults = append(iterResults, map[string]any{
+ "id": iter.ID,
+ "title": iter.Title,
+ "start_date": iter.StartDate,
+ "duration": iter.Duration,
+ })
+ }
+
+ result := map[string]any{
+ "id": field.ID,
+ "name": field.Name,
+ "configuration": map[string]any{
+ "iterations": iterResults,
+ },
+ }
+
+ return MarshalledTextResult(result), nil, nil
+}
+
+// getOwnerNodeID resolves a GitHub user or organization login to its GraphQL node ID.
+func getOwnerNodeID(ctx context.Context, gqlClient *githubv4.Client, owner, ownerType string) (string, error) {
+ if ownerType == "org" {
+ var query struct {
+ Organization struct {
+ ID string
+ } `graphql:"organization(login: $login)"`
+ }
+ variables := map[string]any{
+ "login": githubv4.String(owner),
+ }
+ err := gqlClient.Query(ctx, &query, variables)
+ return query.Organization.ID, err
+ }
+
+ var query struct {
+ User struct {
+ ID string
+ } `graphql:"user(login: $login)"`
+ }
+ variables := map[string]any{
+ "login": githubv4.String(owner),
+ }
+ err := gqlClient.Query(ctx, &query, variables)
+ return query.User.ID, err
+}
+
+// UpdateProjectV2FieldInput is the GraphQL input for the updateProjectV2Field mutation.
+// These types are defined locally because the pinned shurcooL/githubv4 release
+// (v0.0.0-20240727222349) does not yet expose them. Upstream master now generates
+// equivalent types, so this block can be removed when the dependency is next bumped.
+type UpdateProjectV2FieldInput struct {
+ FieldID githubv4.ID `json:"fieldId"`
+ IterationConfiguration *ProjectV2IterationFieldConfigurationInput `json:"iterationConfiguration,omitempty"`
+}
+
+// ProjectV2IterationFieldConfigurationInput is the GraphQL input for configuring an iteration field.
+// GitHub's schema marks iterations as a required non-null list, so the field is not omitempty.
+type ProjectV2IterationFieldConfigurationInput struct {
+ Duration githubv4.Int `json:"duration"`
+ StartDate githubv4.Date `json:"startDate"`
+ Iterations []ProjectV2IterationFieldIterationInput `json:"iterations"`
+}
+
+// ProjectV2IterationFieldIterationInput is the GraphQL input for a single iteration definition.
+type ProjectV2IterationFieldIterationInput struct {
+ StartDate githubv4.Date `json:"startDate"`
+ Duration githubv4.Int `json:"duration"`
+ Title githubv4.String `json:"title"`
+}
+
// detectOwnerType attempts to detect the owner type by trying both user and org
// Returns the detected type ("user" or "org") and any error encountered
func detectOwnerType(ctx context.Context, client *github.Client, owner string, projectNumber int) (string, error) {
diff --git a/pkg/github/projects_test.go b/pkg/github/projects_test.go
index 306c74b41e..ad5ce6db86 100644
--- a/pkg/github/projects_test.go
+++ b/pkg/github/projects_test.go
@@ -569,7 +569,7 @@ func Test_ProjectsWrite(t *testing.T) {
assert.Contains(t, inputSchema.Properties, "issue_number")
assert.Contains(t, inputSchema.Properties, "pull_request_number")
assert.Contains(t, inputSchema.Properties, "updated_field")
- assert.ElementsMatch(t, inputSchema.Required, []string{"method", "owner", "project_number"})
+ assert.ElementsMatch(t, inputSchema.Required, []string{"method", "owner"})
// Verify DestructiveHint is set
assert.NotNil(t, toolDef.Tool.Annotations)
@@ -630,7 +630,8 @@ func Test_ProjectsWrite_AddProjectItem(t *testing.T) {
struct {
AddProjectV2ItemByID struct {
Item struct {
- ID githubv4.ID
+ ID githubv4.ID
+ FullDatabaseID string `graphql:"fullDatabaseId"`
}
} `graphql:"addProjectV2ItemById(input: $input)"`
}{},
@@ -642,7 +643,8 @@ func Test_ProjectsWrite_AddProjectItem(t *testing.T) {
githubv4mock.DataResponse(map[string]any{
"addProjectV2ItemById": map[string]any{
"item": map[string]any{
- "id": "PVTI_item1",
+ "id": "PVTI_item1",
+ "fullDatabaseId": "1001",
},
},
}),
@@ -674,6 +676,8 @@ func Test_ProjectsWrite_AddProjectItem(t *testing.T) {
err = json.Unmarshal([]byte(textContent.Text), &response)
require.NoError(t, err)
assert.NotNil(t, response["id"])
+ assert.Equal(t, float64(1001), response["item_id"])
+ assert.Equal(t, "1001", response["full_database_id"])
assert.Contains(t, response["message"], "Successfully added")
})
@@ -727,7 +731,8 @@ func Test_ProjectsWrite_AddProjectItem(t *testing.T) {
struct {
AddProjectV2ItemByID struct {
Item struct {
- ID githubv4.ID
+ ID githubv4.ID
+ FullDatabaseID string `graphql:"fullDatabaseId"`
}
} `graphql:"addProjectV2ItemById(input: $input)"`
}{},
@@ -739,7 +744,8 @@ func Test_ProjectsWrite_AddProjectItem(t *testing.T) {
githubv4mock.DataResponse(map[string]any{
"addProjectV2ItemById": map[string]any{
"item": map[string]any{
- "id": "PVTI_item2",
+ "id": "PVTI_item2",
+ "fullDatabaseId": "1002",
},
},
}),
@@ -771,6 +777,8 @@ func Test_ProjectsWrite_AddProjectItem(t *testing.T) {
err = json.Unmarshal([]byte(textContent.Text), &response)
require.NoError(t, err)
assert.NotNil(t, response["id"])
+ assert.Equal(t, float64(1002), response["item_id"])
+ assert.Equal(t, "1002", response["full_database_id"])
assert.Contains(t, response["message"], "Successfully added")
})
diff --git a/pkg/github/projects_v2_test.go b/pkg/github/projects_v2_test.go
new file mode 100644
index 0000000000..701e194767
--- /dev/null
+++ b/pkg/github/projects_v2_test.go
@@ -0,0 +1,457 @@
+package github
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "testing"
+ "time"
+
+ "github.com/github/github-mcp-server/internal/githubv4mock"
+ "github.com/github/github-mcp-server/pkg/translations"
+ "github.com/shurcooL/githubv4"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func Test_ProjectsWrite_CreateProject(t *testing.T) {
+ t.Parallel()
+
+ toolDef := ProjectsWrite(translations.NullTranslationHelper)
+
+ t.Run("success user project", func(t *testing.T) {
+ t.Parallel()
+
+ mockedClient := githubv4mock.NewMockedHTTPClient(
+ githubv4mock.NewQueryMatcher(
+ struct {
+ User struct {
+ ID string
+ } `graphql:"user(login: $login)"`
+ }{},
+ map[string]any{
+ "login": githubv4.String("octocat"),
+ },
+ githubv4mock.DataResponse(map[string]any{
+ "user": map[string]any{
+ "id": "U_octocat",
+ },
+ }),
+ ),
+ githubv4mock.NewMutationMatcher(
+ struct {
+ CreateProjectV2 struct {
+ ProjectV2 struct {
+ ID string
+ Number int
+ Title string
+ URL string
+ }
+ } `graphql:"createProjectV2(input: $input)"`
+ }{},
+ githubv4.CreateProjectV2Input{
+ OwnerID: githubv4.ID("U_octocat"),
+ Title: githubv4.String("New Project"),
+ },
+ nil,
+ githubv4mock.DataResponse(map[string]any{
+ "createProjectV2": map[string]any{
+ "projectV2": map[string]any{
+ "id": "PVT_project123",
+ "number": 1,
+ "title": "New Project",
+ "url": "https://github.com/users/octocat/projects/1",
+ },
+ },
+ }),
+ ),
+ )
+
+ deps := BaseDeps{
+ GQLClient: githubv4.NewClient(mockedClient),
+ Obsv: stubExporters(),
+ }
+ handler := toolDef.Handler(deps)
+ request := createMCPRequest(map[string]any{
+ "method": "create_project",
+ "owner": "octocat",
+ "owner_type": "user",
+ "title": "New Project",
+ })
+ result, err := handler(ContextWithDeps(context.Background(), deps), &request)
+
+ require.NoError(t, err)
+ require.False(t, result.IsError)
+
+ textContent := getTextResult(t, result)
+ var response map[string]any
+ err = json.Unmarshal([]byte(textContent.Text), &response)
+ require.NoError(t, err)
+ assert.Equal(t, "PVT_project123", response["id"])
+ assert.Equal(t, float64(1), response["number"])
+ assert.Equal(t, "New Project", response["title"])
+ assert.Equal(t, "https://github.com/users/octocat/projects/1", response["url"])
+ })
+
+ t.Run("missing owner_type returns error", func(t *testing.T) {
+ t.Parallel()
+
+ deps := BaseDeps{
+ GQLClient: githubv4.NewClient(githubv4mock.NewMockedHTTPClient()),
+ Obsv: stubExporters(),
+ }
+ handler := toolDef.Handler(deps)
+ request := createMCPRequest(map[string]any{
+ "method": "create_project",
+ "owner": "octocat",
+ "title": "New Project",
+ })
+ result, err := handler(ContextWithDeps(context.Background(), deps), &request)
+
+ require.NoError(t, err)
+ require.True(t, result.IsError)
+
+ textContent := getTextResult(t, result)
+ assert.Contains(t, textContent.Text, "owner_type is required")
+ })
+
+ t.Run("invalid owner_type returns error", func(t *testing.T) {
+ t.Parallel()
+
+ deps := BaseDeps{
+ GQLClient: githubv4.NewClient(githubv4mock.NewMockedHTTPClient()),
+ Obsv: stubExporters(),
+ }
+ handler := toolDef.Handler(deps)
+ request := createMCPRequest(map[string]any{
+ "method": "create_project",
+ "owner": "octocat",
+ "owner_type": "invalid",
+ "title": "New Project",
+ })
+ result, err := handler(ContextWithDeps(context.Background(), deps), &request)
+
+ require.NoError(t, err)
+ require.True(t, result.IsError)
+
+ textContent := getTextResult(t, result)
+ assert.Contains(t, textContent.Text, "invalid owner_type")
+ assert.Contains(t, textContent.Text, "must be")
+ })
+}
+
+// resolveProjectNodeIDOrgMatcher returns a GraphQL query matcher for resolving
+// an org project node ID via resolveProjectNodeID.
+func resolveProjectNodeIDOrgMatcher(owner string, projectNumber int, nodeID string) githubv4mock.Matcher {
+ return githubv4mock.NewQueryMatcher(
+ struct {
+ Organization struct {
+ ProjectV2 struct {
+ ID githubv4.ID
+ } `graphql:"projectV2(number: $projectNumber)"`
+ } `graphql:"organization(login: $owner)"`
+ }{},
+ map[string]any{
+ "owner": githubv4.String(owner),
+ "projectNumber": githubv4.Int(int32(projectNumber)), //nolint:gosec // test constant
+ },
+ githubv4mock.DataResponse(map[string]any{
+ "organization": map[string]any{
+ "projectV2": map[string]any{
+ "id": nodeID,
+ },
+ },
+ }),
+ )
+}
+
+func createFieldMatcher() githubv4mock.Matcher {
+ return githubv4mock.NewMutationMatcher(
+ struct {
+ CreateProjectV2Field struct {
+ ProjectV2Field struct {
+ ProjectV2IterationField struct {
+ ID string
+ Name string
+ } `graphql:"... on ProjectV2IterationField"`
+ } `graphql:"projectV2Field"`
+ } `graphql:"createProjectV2Field(input: $input)"`
+ }{},
+ githubv4.CreateProjectV2FieldInput{
+ ProjectID: githubv4.ID("PVT_project1"),
+ DataType: githubv4.ProjectV2CustomFieldType("ITERATION"),
+ Name: githubv4.String("Sprint"),
+ },
+ nil,
+ githubv4mock.DataResponse(map[string]any{
+ "createProjectV2Field": map[string]any{
+ "projectV2Field": map[string]any{
+ "id": "PVTIF_field1",
+ "name": "Sprint",
+ },
+ },
+ }),
+ )
+}
+
+func updateFieldIterationResponse() githubv4mock.GQLResponse {
+ return githubv4mock.DataResponse(map[string]any{
+ "updateProjectV2Field": map[string]any{
+ "projectV2Field": map[string]any{
+ "id": "PVTIF_field1",
+ "name": "Sprint",
+ "configuration": map[string]any{
+ "iterations": []any{
+ map[string]any{
+ "id": "PVTI_iter1",
+ "title": "Sprint 1",
+ "startDate": "2025-01-20",
+ "duration": 7,
+ },
+ },
+ },
+ },
+ },
+ })
+}
+
+func Test_ProjectsWrite_CreateIterationField(t *testing.T) {
+ t.Parallel()
+
+ toolDef := ProjectsWrite(translations.NullTranslationHelper)
+
+ t.Run("success with iterations", func(t *testing.T) {
+ t.Parallel()
+
+ mockGQLClient := githubv4mock.NewMockedHTTPClient(
+ resolveProjectNodeIDOrgMatcher("octo-org", 1, "PVT_project1"),
+ createFieldMatcher(),
+ githubv4mock.NewMutationMatcher(
+ struct {
+ UpdateProjectV2Field struct {
+ ProjectV2Field struct {
+ ProjectV2IterationField struct {
+ ID string
+ Name string
+ Configuration struct {
+ Iterations []struct {
+ ID string
+ Title string
+ StartDate string
+ Duration int
+ }
+ }
+ } `graphql:"... on ProjectV2IterationField"`
+ } `graphql:"projectV2Field"`
+ } `graphql:"updateProjectV2Field(input: $input)"`
+ }{},
+ UpdateProjectV2FieldInput{
+ FieldID: githubv4.ID("PVTIF_field1"),
+ IterationConfiguration: &ProjectV2IterationFieldConfigurationInput{
+ Duration: githubv4.Int(7),
+ StartDate: githubv4.Date{Time: time.Date(2025, 1, 20, 0, 0, 0, 0, time.UTC)},
+ Iterations: []ProjectV2IterationFieldIterationInput{
+ {
+ Title: githubv4.String("Sprint 1"),
+ StartDate: githubv4.Date{Time: time.Date(2025, 1, 20, 0, 0, 0, 0, time.UTC)},
+ Duration: githubv4.Int(7),
+ },
+ },
+ },
+ },
+ nil,
+ updateFieldIterationResponse(),
+ ),
+ )
+
+ deps := BaseDeps{
+ GQLClient: githubv4.NewClient(mockGQLClient),
+ Obsv: stubExporters(),
+ }
+ handler := toolDef.Handler(deps)
+ request := createMCPRequest(map[string]any{
+ "method": "create_iteration_field",
+ "owner": "octo-org",
+ "owner_type": "org",
+ "project_number": float64(1),
+ "field_name": "Sprint",
+ "iteration_duration": float64(7),
+ "start_date": "2025-01-20",
+ "iterations": []any{
+ map[string]any{
+ "title": "Sprint 1",
+ "start_date": "2025-01-20",
+ "duration": float64(7),
+ },
+ },
+ })
+ result, err := handler(ContextWithDeps(context.Background(), deps), &request)
+
+ require.NoError(t, err)
+ require.False(t, result.IsError)
+
+ textContent := getTextResult(t, result)
+ var response map[string]any
+ err = json.Unmarshal([]byte(textContent.Text), &response)
+ require.NoError(t, err)
+ assert.Equal(t, "PVTIF_field1", response["id"])
+ })
+
+ t.Run("success without iterations", func(t *testing.T) {
+ t.Parallel()
+
+ mockGQLClient := githubv4mock.NewMockedHTTPClient(
+ resolveProjectNodeIDOrgMatcher("octo-org", 1, "PVT_project1"),
+ createFieldMatcher(),
+ githubv4mock.NewMutationMatcher(
+ struct {
+ UpdateProjectV2Field struct {
+ ProjectV2Field struct {
+ ProjectV2IterationField struct {
+ ID string
+ Name string
+ Configuration struct {
+ Iterations []struct {
+ ID string
+ Title string
+ StartDate string
+ Duration int
+ }
+ }
+ } `graphql:"... on ProjectV2IterationField"`
+ } `graphql:"projectV2Field"`
+ } `graphql:"updateProjectV2Field(input: $input)"`
+ }{},
+ UpdateProjectV2FieldInput{
+ FieldID: githubv4.ID("PVTIF_field1"),
+ IterationConfiguration: &ProjectV2IterationFieldConfigurationInput{
+ Duration: githubv4.Int(7),
+ StartDate: githubv4.Date{Time: time.Date(2025, 1, 20, 0, 0, 0, 0, time.UTC)},
+ Iterations: []ProjectV2IterationFieldIterationInput{},
+ },
+ },
+ nil,
+ githubv4mock.DataResponse(map[string]any{
+ "updateProjectV2Field": map[string]any{
+ "projectV2Field": map[string]any{
+ "id": "PVTIF_field1",
+ "name": "Sprint",
+ "configuration": map[string]any{
+ "iterations": []any{},
+ },
+ },
+ },
+ }),
+ ),
+ )
+
+ deps := BaseDeps{
+ GQLClient: githubv4.NewClient(mockGQLClient),
+ Obsv: stubExporters(),
+ }
+ handler := toolDef.Handler(deps)
+ request := createMCPRequest(map[string]any{
+ "method": "create_iteration_field",
+ "owner": "octo-org",
+ "owner_type": "org",
+ "project_number": float64(1),
+ "field_name": "Sprint",
+ "iteration_duration": float64(7),
+ "start_date": "2025-01-20",
+ })
+ result, err := handler(ContextWithDeps(context.Background(), deps), &request)
+
+ require.NoError(t, err)
+ require.False(t, result.IsError)
+
+ textContent := getTextResult(t, result)
+ var response map[string]any
+ err = json.Unmarshal([]byte(textContent.Text), &response)
+ require.NoError(t, err)
+ assert.Equal(t, "PVTIF_field1", response["id"])
+ })
+
+ t.Run("success with auto-detected owner_type", func(t *testing.T) {
+ t.Parallel()
+
+ // detectOwnerType uses REST to probe user first, then org
+ mockRESTClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
+ GetUsersProjectsV2ByUsernameByProject: mockResponse(t, http.StatusNotFound, nil),
+ GetOrgsProjectsV2ByProject: mockResponse(t, http.StatusOK, map[string]any{
+ "id": 1,
+ "node_id": "PVT_project1",
+ "title": "Org Project",
+ }),
+ })
+
+ mockGQLClient := githubv4mock.NewMockedHTTPClient(
+ resolveProjectNodeIDOrgMatcher("octo-org", 1, "PVT_project1"),
+ createFieldMatcher(),
+ githubv4mock.NewMutationMatcher(
+ struct {
+ UpdateProjectV2Field struct {
+ ProjectV2Field struct {
+ ProjectV2IterationField struct {
+ ID string
+ Name string
+ Configuration struct {
+ Iterations []struct {
+ ID string
+ Title string
+ StartDate string
+ Duration int
+ }
+ }
+ } `graphql:"... on ProjectV2IterationField"`
+ } `graphql:"projectV2Field"`
+ } `graphql:"updateProjectV2Field(input: $input)"`
+ }{},
+ UpdateProjectV2FieldInput{
+ FieldID: githubv4.ID("PVTIF_field1"),
+ IterationConfiguration: &ProjectV2IterationFieldConfigurationInput{
+ Duration: githubv4.Int(14),
+ StartDate: githubv4.Date{Time: time.Date(2025, 2, 1, 0, 0, 0, 0, time.UTC)},
+ Iterations: []ProjectV2IterationFieldIterationInput{},
+ },
+ },
+ nil,
+ githubv4mock.DataResponse(map[string]any{
+ "updateProjectV2Field": map[string]any{
+ "projectV2Field": map[string]any{
+ "id": "PVTIF_field1",
+ "name": "Sprint",
+ "configuration": map[string]any{
+ "iterations": []any{},
+ },
+ },
+ },
+ }),
+ ),
+ )
+
+ deps := BaseDeps{
+ Client: mustNewGHClient(t, mockRESTClient),
+ GQLClient: githubv4.NewClient(mockGQLClient),
+ Obsv: stubExporters(),
+ }
+ handler := toolDef.Handler(deps)
+ request := createMCPRequest(map[string]any{
+ "method": "create_iteration_field",
+ "owner": "octo-org",
+ "project_number": float64(1),
+ "field_name": "Sprint",
+ "iteration_duration": float64(14),
+ "start_date": "2025-02-01",
+ })
+ result, err := handler(ContextWithDeps(context.Background(), deps), &request)
+
+ require.NoError(t, err)
+ require.False(t, result.IsError)
+
+ textContent := getTextResult(t, result)
+ var response map[string]any
+ err = json.Unmarshal([]byte(textContent.Text), &response)
+ require.NoError(t, err)
+ assert.Equal(t, "PVTIF_field1", response["id"])
+ })
+}
diff --git a/pkg/github/pullrequests.go b/pkg/github/pullrequests.go
index 3910a96b95..ae7d04331d 100644
--- a/pkg/github/pullrequests.go
+++ b/pkg/github/pullrequests.go
@@ -14,6 +14,7 @@ import (
"github.com/shurcooL/githubv4"
ghErrors "github.com/github/github-mcp-server/pkg/errors"
+ "github.com/github/github-mcp-server/pkg/ifc"
"github.com/github/github-mcp-server/pkg/inventory"
"github.com/github/github-mcp-server/pkg/octicons"
"github.com/github/github-mcp-server/pkg/sanitize"
@@ -35,12 +36,13 @@ Possible options:
2. get_diff - Get the diff of a pull request.
3. get_status - Get combined commit status of a head commit in a pull request.
4. get_files - Get the list of files changed in a pull request. Use with pagination parameters to control the number of results returned.
- 5. 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.
- 6. 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.
- 7. 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.
- 8. 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.
+ 5. get_commits - Get the list of commits on a pull request. Use with pagination parameters to control the number of results returned.
+ 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.
+ 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.
+ 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.
+ 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.
`,
- Enum: []any{"get", "get_diff", "get_status", "get_files", "get_review_comments", "get_reviews", "get_comments", "get_check_runs"},
+ Enum: []any{"get", "get_diff", "get_status", "get_files", "get_commits", "get_review_comments", "get_reviews", "get_comments", "get_check_runs"},
},
"owner": {
Type: "string",
@@ -106,19 +108,32 @@ Possible options:
return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil
}
+ // attachIFC adds the IFC label to a successful tool result when
+ // IFC labels are enabled. Pull request content (descriptions,
+ // diffs, comments, reviews) is user-authored and therefore
+ // untrusted; confidentiality follows repo visibility. If the
+ // visibility lookup fails the label is omitted rather than
+ // misclassifying the result.
+ attachIFC := func(r *mcp.CallToolResult) *mcp.CallToolResult {
+ return attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, r, ifc.LabelListIssues)
+ }
+
switch method {
case "get":
result, err := GetPullRequest(ctx, client, deps, owner, repo, pullNumber)
- return result, nil, err
+ return attachIFC(result), nil, err
case "get_diff":
result, err := GetPullRequestDiff(ctx, client, owner, repo, pullNumber)
- return result, nil, err
+ return attachIFC(result), nil, err
case "get_status":
result, err := GetPullRequestStatus(ctx, client, owner, repo, pullNumber)
- return result, nil, err
+ return attachIFC(result), nil, err
case "get_files":
result, err := GetPullRequestFiles(ctx, client, owner, repo, pullNumber, pagination)
- return result, nil, err
+ return attachIFC(result), nil, err
+ case "get_commits":
+ result, err := GetPullRequestCommits(ctx, client, owner, repo, pullNumber, pagination)
+ return attachIFC(result), nil, err
case "get_review_comments":
gqlClient, err := deps.GetGQLClient(ctx)
if err != nil {
@@ -129,16 +144,16 @@ Possible options:
return utils.NewToolResultError(err.Error()), nil, nil
}
result, err := GetPullRequestReviewComments(ctx, gqlClient, deps, owner, repo, pullNumber, cursorPagination)
- return result, nil, err
+ return attachIFC(result), nil, err
case "get_reviews":
result, err := GetPullRequestReviews(ctx, client, deps, owner, repo, pullNumber, pagination)
- return result, nil, err
+ return attachIFC(result), nil, err
case "get_comments":
result, err := GetIssueComments(ctx, client, deps, owner, repo, pullNumber, pagination)
- return result, nil, err
+ return attachIFC(result), nil, err
case "get_check_runs":
result, err := GetPullRequestCheckRuns(ctx, client, owner, repo, pullNumber, pagination)
- return result, nil, err
+ return attachIFC(result), nil, err
default:
return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil
}
@@ -371,6 +386,34 @@ func GetPullRequestFiles(ctx context.Context, client *github.Client, owner, repo
return MarshalledTextResult(minimalFiles), nil
}
+func GetPullRequestCommits(ctx context.Context, client *github.Client, owner, repo string, pullNumber int, pagination PaginationParams) (*mcp.CallToolResult, error) {
+ opts := &github.ListOptions{
+ PerPage: pagination.PerPage,
+ Page: pagination.Page,
+ }
+ commits, resp, err := client.PullRequests.ListCommits(ctx, owner, repo, pullNumber, opts)
+ if err != nil {
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ "failed to get pull request commits",
+ resp,
+ err,
+ ), nil
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode != http.StatusOK {
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read response body: %w", err)
+ }
+ return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get pull request commits", resp, body), nil
+ }
+
+ minimalCommits := convertToMinimalPullRequestCommits(commits)
+
+ return MarshalledTextResult(minimalCommits), nil
+}
+
// GraphQL types for review threads query
type reviewThreadsQuery struct {
Repository struct {
@@ -544,6 +587,37 @@ func GetPullRequestReviews(ctx context.Context, client *github.Client, deps Tool
// PullRequestWriteUIResourceURI is the URI for the create_pull_request tool's MCP App UI resource.
const PullRequestWriteUIResourceURI = "ui://github-mcp-server/pr-write"
+// pullRequestWriteFormParams are the parameters the create_pull_request MCP App
+// form collects and re-sends on submit. Any other parameter present on a call
+// cannot be represented by the form.
+var pullRequestWriteFormParams = map[string]struct{}{
+ "owner": {},
+ "repo": {},
+ "title": {},
+ "body": {},
+ "head": {},
+ "base": {},
+ "draft": {},
+ "maintainer_can_modify": {},
+ "_ui_submitted": {},
+}
+
+// pullRequestWriteHasNonFormParams reports whether the call carries any parameter
+// the create_pull_request MCP App form cannot represent (anything outside
+// pullRequestWriteFormParams). Such calls must bypass the UI form and execute
+// directly so the supplied values aren't silently dropped.
+func pullRequestWriteHasNonFormParams(args map[string]any) bool {
+ for key, value := range args {
+ if value == nil {
+ continue
+ }
+ if _, ok := pullRequestWriteFormParams[key]; !ok {
+ return true
+ }
+ }
+ return false
+}
+
// CreatePullRequest creates a tool to create a new pull request.
func CreatePullRequest(t translations.TranslationHelperFunc) inventory.ServerTool {
return NewTool(
@@ -611,12 +685,14 @@ func CreatePullRequest(t translations.TranslationHelperFunc) inventory.ServerToo
return utils.NewToolResultError(err.Error()), nil, nil
}
- // When MCP Apps are enabled and the client supports UI,
- // check if this is a UI form submission. The UI sends _ui_submitted=true
- // to distinguish form submissions from LLM calls.
+ // When MCP Apps are enabled and the client supports UI, route the
+ // call to the interactive form unless it is itself a form submission
+ // (the UI sends _ui_submitted=true) or it carries parameters the form
+ // cannot represent. Those must be applied directly so their values
+ // aren't silently dropped.
uiSubmitted, _ := OptionalParam[bool](args, "_ui_submitted")
- if deps.IsFeatureEnabled(ctx, MCPAppsFeatureFlag) && clientSupportsUI(ctx, req) && !uiSubmitted {
+ if deps.IsFeatureEnabled(ctx, MCPAppsFeatureFlag) && clientSupportsUI(ctx, req) && !uiSubmitted && !pullRequestWriteHasNonFormParams(args) {
return utils.NewToolResultText(fmt.Sprintf("Ready to create a pull request in %s/%s. IMPORTANT: The PR has NOT been created yet. Do NOT tell the user the PR was created. The user MUST click Submit in the form to create it.", owner, repo)), nil, nil
}
@@ -752,7 +828,7 @@ func UpdatePullRequest(t translations.TranslationHelperFunc) inventory.ServerToo
},
"reviewers": {
Type: "array",
- Description: "GitHub usernames to request reviews from",
+ Description: "GitHub usernames or ORG/team-slug team reviewers to request reviews from",
Items: &jsonschema.Schema{
Type: "string",
},
@@ -944,8 +1020,10 @@ func UpdatePullRequest(t translations.TranslationHelperFunc) inventory.ServerToo
return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil
}
+ userReviewers, teamReviewers := splitPullRequestReviewers(reviewers)
reviewersRequest := github.ReviewersRequest{
- Reviewers: reviewers,
+ Reviewers: userReviewers,
+ TeamReviewers: teamReviewers,
}
_, resp, err := client.PullRequests.RequestReviewers(ctx, owner, repo, pullNumber, reviewersRequest)
@@ -1241,7 +1319,11 @@ func ListPullRequests(t translations.TranslationHelperFunc) inventory.ServerTool
return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil
}
- return utils.NewToolResultText(string(r)), nil, nil
+ result := utils.NewToolResultText(string(r))
+ // Pull request titles/bodies are user-authored (untrusted);
+ // confidentiality follows repo visibility.
+ result = attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, result, ifc.LabelListIssues)
+ return result, nil, nil
})
}
@@ -1411,7 +1493,7 @@ func SearchPullRequests(t translations.TranslationHelperFunc) inventory.ServerTo
},
[]scopes.Scope{scopes.Repo},
func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
- result, err := searchHandler(ctx, deps.GetClient, args, "pr", "failed to search pull requests")
+ result, err := searchHandler(ctx, deps.GetClient, args, "pr", "failed to search pull requests", ifcSearchPostProcessOption(ctx, deps))
return result, nil, err
})
}
diff --git a/pkg/github/pullrequests_granular.go b/pkg/github/pullrequests_granular.go
index 30d7f78d62..6bc2b99f36 100644
--- a/pkg/github/pullrequests_granular.go
+++ b/pkg/github/pullrequests_granular.go
@@ -297,7 +297,7 @@ func GranularRequestPullRequestReviewers(t translations.TranslationHelperFunc) i
"pullNumber": {Type: "number", Description: "The pull request number", Minimum: jsonschema.Ptr(1.0)},
"reviewers": {
Type: "array",
- Description: "GitHub usernames to request reviews from",
+ Description: "GitHub usernames or ORG/team-slug team reviewers to request reviews from",
Items: &jsonschema.Schema{Type: "string"},
},
},
@@ -325,13 +325,17 @@ func GranularRequestPullRequestReviewers(t translations.TranslationHelperFunc) i
if len(reviewers) == 0 {
return utils.NewToolResultError("missing required parameter: reviewers"), nil, nil
}
+ userReviewers, teamReviewers := splitPullRequestReviewers(reviewers)
client, err := deps.GetClient(ctx)
if err != nil {
return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil
}
- pr, resp, err := client.PullRequests.RequestReviewers(ctx, owner, repo, pullNumber, gogithub.ReviewersRequest{Reviewers: reviewers})
+ pr, resp, err := client.PullRequests.RequestReviewers(ctx, owner, repo, pullNumber, gogithub.ReviewersRequest{
+ Reviewers: userReviewers,
+ TeamReviewers: teamReviewers,
+ })
if err != nil {
return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to request reviewers", resp, err), nil, nil
}
@@ -351,6 +355,22 @@ func GranularRequestPullRequestReviewers(t translations.TranslationHelperFunc) i
return st
}
+func splitPullRequestReviewers(reviewers []string) ([]string, []string) {
+ userReviewers := make([]string, 0, len(reviewers))
+ teamReviewers := make([]string, 0)
+
+ for _, reviewer := range reviewers {
+ org, team, ok := strings.Cut(reviewer, "/")
+ if ok && org != "" && team != "" && !strings.Contains(team, "/") {
+ teamReviewers = append(teamReviewers, team)
+ continue
+ }
+ userReviewers = append(userReviewers, reviewer)
+ }
+
+ return userReviewers, teamReviewers
+}
+
// GranularCreatePullRequestReview creates a tool to create a PR review.
func GranularCreatePullRequestReview(t translations.TranslationHelperFunc) inventory.ServerTool {
st := NewTool(
diff --git a/pkg/github/pullrequests_test.go b/pkg/github/pullrequests_test.go
index 097651b66e..2b911636a9 100644
--- a/pkg/github/pullrequests_test.go
+++ b/pkg/github/pullrequests_test.go
@@ -257,6 +257,24 @@ func Test_UpdatePullRequest(t *testing.T) {
expectError: false,
expectedPR: mockPRWithReviewers,
},
+ {
+ name: "successful PR update with user and team reviewers",
+ mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
+ PostReposPullsRequestedReviewersByOwnerByRepoByPullNumber: expectRequestBody(t, map[string]any{
+ "reviewers": []any{"reviewer1"},
+ "team_reviewers": []any{"platform"},
+ }).andThen(mockResponse(t, http.StatusOK, mockPRWithReviewers)),
+ GetReposPullsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, mockPRWithReviewers),
+ }),
+ requestArgs: map[string]any{
+ "owner": "owner",
+ "repo": "repo",
+ "pullNumber": float64(42),
+ "reviewers": []any{"reviewer1", "owner/platform"},
+ },
+ expectError: false,
+ expectedPR: mockPRWithReviewers,
+ },
{
name: "successful PR update (title only)",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
@@ -1242,6 +1260,185 @@ func Test_GetPullRequestFiles(t *testing.T) {
}
}
+func Test_GetPullRequestCommits(t *testing.T) {
+ // Verify tool definition once
+ serverTool := PullRequestRead(translations.NullTranslationHelper)
+ tool := serverTool.Tool
+ require.NoError(t, toolsnaps.Test(tool.Name, tool))
+
+ assert.Equal(t, "pull_request_read", tool.Name)
+ assert.NotEmpty(t, tool.Description)
+ schema := tool.InputSchema.(*jsonschema.Schema)
+ assert.Contains(t, schema.Properties, "method")
+ assert.Contains(t, schema.Properties, "owner")
+ assert.Contains(t, schema.Properties, "repo")
+ assert.Contains(t, schema.Properties, "pullNumber")
+ assert.Contains(t, schema.Properties, "page")
+ assert.Contains(t, schema.Properties, "perPage")
+ assert.ElementsMatch(t, schema.Required, []string{"method", "owner", "repo", "pullNumber"})
+
+ authorDate := time.Date(2026, 4, 1, 12, 0, 0, 0, time.UTC)
+ mockCommits := []*github.RepositoryCommit{
+ {
+ SHA: github.Ptr("abc123def456"),
+ HTMLURL: github.Ptr("https://github.com/owner/repo/commit/abc123def456"),
+ Commit: &github.Commit{
+ Message: github.Ptr("feat: add commit listing"),
+ Author: &github.CommitAuthor{
+ Name: github.Ptr("Test User"),
+ Email: github.Ptr("test@example.com"),
+ Date: &github.Timestamp{Time: authorDate},
+ },
+ Committer: &github.CommitAuthor{
+ Name: github.Ptr("Merge Bot"),
+ Email: github.Ptr("merge@example.com"),
+ Date: &github.Timestamp{Time: authorDate.Add(30 * time.Minute)},
+ },
+ },
+ Author: &github.User{
+ Login: github.Ptr("test-user"),
+ ID: github.Ptr(int64(12345)),
+ HTMLURL: github.Ptr("https://github.com/test-user"),
+ AvatarURL: github.Ptr("https://github.com/test-user.png"),
+ },
+ Committer: &github.User{
+ Login: github.Ptr("merge-bot"),
+ ID: github.Ptr(int64(67890)),
+ HTMLURL: github.Ptr("https://github.com/merge-bot"),
+ AvatarURL: github.Ptr("https://github.com/merge-bot.png"),
+ },
+ },
+ {
+ SHA: github.Ptr("def456abc789"),
+ HTMLURL: github.Ptr("https://github.com/owner/repo/commit/def456abc789"),
+ Commit: &github.Commit{
+ Message: github.Ptr("fix: handle pagination"),
+ },
+ },
+ }
+
+ tests := []struct {
+ name string
+ mockedClient *http.Client
+ requestArgs map[string]any
+ expectError bool
+ expectedCommits []*github.RepositoryCommit
+ expectedErrMsg string
+ }{
+ {
+ name: "successful commits fetch",
+ mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
+ GetReposPullsCommitsByOwnerByRepoByPullNumber: expectQueryParams(t, map[string]string{
+ "page": "1",
+ "per_page": "30",
+ }).andThen(
+ mockResponse(t, http.StatusOK, mockCommits),
+ ),
+ }),
+ requestArgs: map[string]any{
+ "method": "get_commits",
+ "owner": "owner",
+ "repo": "repo",
+ "pullNumber": float64(42),
+ },
+ expectError: false,
+ expectedCommits: mockCommits,
+ },
+ {
+ name: "successful commits fetch with pagination",
+ mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
+ GetReposPullsCommitsByOwnerByRepoByPullNumber: expectQueryParams(t, map[string]string{
+ "page": "2",
+ "per_page": "10",
+ }).andThen(
+ mockResponse(t, http.StatusOK, mockCommits),
+ ),
+ }),
+ requestArgs: map[string]any{
+ "method": "get_commits",
+ "owner": "owner",
+ "repo": "repo",
+ "pullNumber": float64(42),
+ "page": float64(2),
+ "perPage": float64(10),
+ },
+ expectError: false,
+ expectedCommits: mockCommits,
+ },
+ {
+ name: "commits fetch fails",
+ mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
+ GetReposPullsCommitsByOwnerByRepoByPullNumber: expectQueryParams(t, map[string]string{
+ "page": "1",
+ "per_page": "30",
+ }).andThen(
+ http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusNotFound)
+ _, _ = w.Write([]byte(`{"message": "Not Found"}`))
+ }),
+ ),
+ }),
+ requestArgs: map[string]any{
+ "method": "get_commits",
+ "owner": "owner",
+ "repo": "repo",
+ "pullNumber": float64(999),
+ },
+ expectError: true,
+ expectedErrMsg: "failed to get pull request commits",
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ client := mustNewGHClient(t, tc.mockedClient)
+ serverTool := PullRequestRead(translations.NullTranslationHelper)
+ deps := BaseDeps{
+ Client: client,
+ RepoAccessCache: stubRepoAccessCache(nil, 5*time.Minute),
+ Flags: stubFeatureFlags(map[string]bool{"lockdown-mode": false}),
+ }
+ handler := serverTool.Handler(deps)
+ request := createMCPRequest(tc.requestArgs)
+
+ result, err := handler(ContextWithDeps(context.Background(), deps), &request)
+
+ if tc.expectError {
+ require.NoError(t, err)
+ require.True(t, result.IsError)
+ errorContent := getErrorResult(t, result)
+ assert.Contains(t, errorContent.Text, tc.expectedErrMsg)
+ return
+ }
+
+ require.NoError(t, err)
+ require.False(t, result.IsError)
+
+ textContent := getTextResult(t, result)
+ assert.NotContains(t, textContent.Text, `"committer"`)
+ assert.NotContains(t, textContent.Text, `"profile_url"`)
+
+ var returnedCommits []MinimalPullRequestCommit
+ err = json.Unmarshal([]byte(textContent.Text), &returnedCommits)
+ require.NoError(t, err)
+ assert.Len(t, returnedCommits, len(tc.expectedCommits))
+ for i, commit := range returnedCommits {
+ assert.Equal(t, tc.expectedCommits[i].GetSHA(), commit.SHA)
+ assert.Equal(t, tc.expectedCommits[i].GetHTMLURL(), commit.HTMLURL)
+ assert.Equal(t, tc.expectedCommits[i].GetCommit().GetMessage(), commit.Message)
+ }
+
+ assert.Equal(t, authorDate.Format(time.RFC3339), returnedCommits[0].Author.Date)
+ })
+ }
+}
+
+func Test_ConvertToMinimalPullRequestCommitsSkipsNilCommit(t *testing.T) {
+ commits := convertToMinimalPullRequestCommits([]*github.RepositoryCommit{nil})
+
+ require.Empty(t, commits)
+}
+
func Test_GetPullRequestStatus(t *testing.T) {
// Verify tool definition once
serverTool := PullRequestRead(translations.NullTranslationHelper)
@@ -2467,6 +2664,49 @@ func Test_CreatePullRequest_MCPAppsFeature_UIGate(t *testing.T) {
assert.Contains(t, textContent.Text, "https://github.com/owner/repo/pull/42",
"non-UI client should execute directly")
})
+
+ t.Run("UI client with non-form param skips form and executes directly", func(t *testing.T) {
+ // A parameter the form does not collect must bypass the form rather than
+ // be silently dropped.
+ request := createMCPRequestWithSession(t, ClientNameVSCodeInsiders, true, map[string]any{
+ "owner": "owner",
+ "repo": "repo",
+ "title": "Test PR",
+ "head": "feature",
+ "base": "main",
+ "reviewers": []any{"octocat"},
+ })
+ result, err := handler(ContextWithDeps(context.Background(), deps), &request)
+ require.NoError(t, err)
+
+ textContent := getTextResult(t, result)
+ assert.NotContains(t, textContent.Text, "Ready to create a pull request",
+ "non-form param should skip UI form")
+ assert.Contains(t, textContent.Text, "https://github.com/owner/repo/pull/42",
+ "non-form param call should execute directly and return PR URL")
+ })
+}
+
+func Test_pullRequestWriteHasNonFormParams(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ args map[string]any
+ want bool
+ }{
+ {name: "no params", args: map[string]any{}, want: false},
+ {name: "only form params", args: map[string]any{"owner": "o", "repo": "r", "title": "t", "body": "b", "head": "h", "base": "b", "draft": true, "maintainer_can_modify": false, "_ui_submitted": true}, want: false},
+ {name: "unknown param present", args: map[string]any{"title": "t", "reviewers": []any{"octocat"}}, want: true},
+ {name: "nil value is ignored", args: map[string]any{"reviewers": nil}, want: false},
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+ assert.Equal(t, tc.want, pullRequestWriteHasNonFormParams(tc.args))
+ })
+ }
}
func TestCreateAndSubmitPullRequestReview(t *testing.T) {
diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go
index d682b5c3d7..60bb45c44f 100644
--- a/pkg/github/repositories.go
+++ b/pkg/github/repositories.go
@@ -7,6 +7,8 @@ import (
"fmt"
"io"
"net/http"
+ "slices"
+ "strconv"
"strings"
ghErrors "github.com/github/github-mcp-server/pkg/errors"
@@ -19,6 +21,7 @@ import (
"github.com/google/go-github/v87/github"
"github.com/google/jsonschema-go/jsonschema"
"github.com/modelcontextprotocol/go-sdk/mcp"
+ "github.com/shurcooL/githubv4"
)
func GetCommit(t translations.TranslationHelperFunc) inventory.ServerTool {
@@ -46,10 +49,11 @@ func GetCommit(t translations.TranslationHelperFunc) inventory.ServerTool {
Type: "string",
Description: "Commit SHA, branch name, or tag name",
},
- "include_diff": {
- Type: "boolean",
- Description: "Whether to include file diffs and stats in the response. Default is true.",
- Default: json.RawMessage(`true`),
+ "detail": {
+ Type: "string",
+ Enum: []any{"none", "stats", "full_patch"},
+ 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.",
+ Default: json.RawMessage(`"stats"`),
},
},
Required: []string{"owner", "repo", "sha"},
@@ -69,7 +73,11 @@ func GetCommit(t translations.TranslationHelperFunc) inventory.ServerTool {
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
- includeDiff, err := OptionalBoolParamWithDefault(args, "include_diff", true)
+ detailRaw, err := OptionalParam[string](args, "detail")
+ if err != nil {
+ return utils.NewToolResultError(err.Error()), nil, nil
+ }
+ detail, err := parseCommitDetail(detailRaw)
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
@@ -106,14 +114,20 @@ func GetCommit(t translations.TranslationHelperFunc) inventory.ServerTool {
}
// Convert to minimal commit
- minimalCommit := convertToMinimalCommit(commit, includeDiff)
+ minimalCommit := convertToMinimalCommit(commit, detail)
r, err := json.Marshal(minimalCommit)
if err != nil {
return nil, nil, fmt.Errorf("failed to marshal response: %w", err)
}
- return utils.NewToolResultText(string(r)), nil, nil
+ result := utils.NewToolResultText(string(r))
+ // Commit content is reachable from the repo's history; in public
+ // repos anyone can land it via a PR (untrusted), in private repos
+ // only collaborators can (trusted). Confidentiality follows repo
+ // visibility.
+ result = attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, result, ifc.LabelCommitContents)
+ return result, nil, nil
},
)
}
@@ -252,7 +266,7 @@ func ListCommits(t translations.TranslationHelperFunc) inventory.ServerTool {
// Convert to minimal commits
minimalCommits := make([]MinimalCommit, len(commits))
for i, commit := range commits {
- minimalCommits[i] = convertToMinimalCommit(commit, false)
+ minimalCommits[i] = convertToMinimalCommit(commit, commitDetailNone)
}
r, err := json.Marshal(minimalCommits)
@@ -260,7 +274,12 @@ func ListCommits(t translations.TranslationHelperFunc) inventory.ServerTool {
return nil, nil, fmt.Errorf("failed to marshal response: %w", err)
}
- return utils.NewToolResultText(string(r)), nil, nil
+ result := utils.NewToolResultText(string(r))
+ // Commit content is reachable from the repo's history; integrity
+ // follows the same public-untrusted / private-trusted rule as file
+ // contents. Confidentiality follows repo visibility.
+ result = attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, result, ifc.LabelCommitContents)
+ return result, nil, nil
},
)
}
@@ -347,7 +366,12 @@ func ListBranches(t translations.TranslationHelperFunc) inventory.ServerTool {
return nil, nil, fmt.Errorf("failed to marshal response: %w", err)
}
- return utils.NewToolResultText(string(r)), nil, nil
+ result := utils.NewToolResultText(string(r))
+ // Branches are structural repo metadata that only collaborators
+ // with push access can create, so integrity is trusted.
+ // Confidentiality follows repo visibility.
+ result = attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, result, ifc.LabelRepoMetadata)
+ return result, nil, nil
},
)
}
@@ -747,28 +771,7 @@ func GetFileContents(t translations.TranslationHelperFunc) inventory.ServerTool
// each. If the visibility lookup fails we skip the label rather
// than misclassify the result; the failure is not cached so a
// later return path can retry.
- var (
- ifcLabelKnown bool
- ifcIsPrivate bool
- )
- attachIFC := func(r *mcp.CallToolResult) *mcp.CallToolResult {
- if r == nil || r.IsError || !deps.IsFeatureEnabled(ctx, FeatureFlagIFCLabels) {
- return r
- }
- if !ifcLabelKnown {
- isPrivate, err := FetchRepoIsPrivate(ctx, client, owner, repo)
- if err != nil {
- return r
- }
- ifcIsPrivate = isPrivate
- ifcLabelKnown = true
- }
- if r.Meta == nil {
- r.Meta = mcp.Meta{}
- }
- r.Meta["ifc"] = ifc.LabelGetFileContents(ifcIsPrivate)
- return r
- }
+ attachIFC := newRepoVisibilityIFCLabeler(ctx, deps, client, owner, repo, ifc.LabelGetFileContents)
rawOpts, fallbackUsed, err := resolveGitReference(ctx, client, owner, repo, ref, sha)
if err != nil {
@@ -1604,7 +1607,12 @@ func ListTags(t translations.TranslationHelperFunc) inventory.ServerTool {
return nil, nil, fmt.Errorf("failed to marshal response: %w", err)
}
- return utils.NewToolResultText(string(r)), nil, nil
+ result := utils.NewToolResultText(string(r))
+ // Tags are structural repo metadata created by collaborators with
+ // push access, so integrity is trusted. Confidentiality follows
+ // repo visibility.
+ result = attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, result, ifc.LabelRepoMetadata)
+ return result, nil, nil
},
)
}
@@ -1684,7 +1692,9 @@ func GetTag(t translations.TranslationHelperFunc) inventory.ServerTool {
if err != nil {
return nil, nil, fmt.Errorf("failed to marshal response: %w", err)
}
- return utils.NewToolResultText(string(r)), nil, nil
+ result := utils.NewToolResultText(string(r))
+ result = attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, result, ifc.LabelRepoMetadata)
+ return result, nil, nil
}
tagObj, resp, err := client.Git.GetTag(ctx, owner, repo, *ref.Object.SHA)
@@ -1710,7 +1720,12 @@ func GetTag(t translations.TranslationHelperFunc) inventory.ServerTool {
return nil, nil, fmt.Errorf("failed to marshal response: %w", err)
}
- return utils.NewToolResultText(string(r)), nil, nil
+ result := utils.NewToolResultText(string(r))
+ // An annotated tag object is structural repo metadata created by a
+ // collaborator with push access. Confidentiality follows repo
+ // visibility.
+ result = attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, result, ifc.LabelRepoMetadata)
+ return result, nil, nil
},
)
}
@@ -1792,7 +1807,24 @@ func ListReleases(t translations.TranslationHelperFunc) inventory.ServerTool {
return nil, nil, fmt.Errorf("failed to marshal response: %w", err)
}
- return utils.NewToolResultText(string(r)), nil, nil
+ result := utils.NewToolResultText(string(r))
+ // Releases are published by collaborators with push access, so
+ // integrity is trusted. Confidentiality follows repo visibility,
+ // but draft releases are visible only to push-access users and are
+ // not world-readable even on a public repo, so the result is only
+ // public when no returned release is a draft.
+ hasDraft := false
+ for _, mr := range minimalReleases {
+ if mr.Draft {
+ hasDraft = true
+ break
+ }
+ }
+ result = attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, result,
+ func(isPrivate bool) ifc.SecurityLabel {
+ return ifc.LabelRelease(isPrivate, hasDraft)
+ })
+ return result, nil, nil
},
)
}
@@ -1858,7 +1890,16 @@ func GetLatestRelease(t translations.TranslationHelperFunc) inventory.ServerTool
return nil, nil, fmt.Errorf("failed to marshal response: %w", err)
}
- return utils.NewToolResultText(string(r)), nil, nil
+ result := utils.NewToolResultText(string(r))
+ // Releases are published by collaborators with push access, so
+ // integrity is trusted. The "latest release" endpoint never returns
+ // a draft, but the draft flag is honored defensively: a draft is
+ // not world-readable even on a public repo.
+ result = attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, result,
+ func(isPrivate bool) ifc.SecurityLabel {
+ return ifc.LabelRelease(isPrivate, release.GetDraft())
+ })
+ return result, nil, nil
},
)
}
@@ -1935,7 +1976,16 @@ func GetReleaseByTag(t translations.TranslationHelperFunc) inventory.ServerTool
return nil, nil, fmt.Errorf("failed to marshal response: %w", err)
}
- return utils.NewToolResultText(string(r)), nil, nil
+ result := utils.NewToolResultText(string(r))
+ // Releases are published by collaborators with push access, so
+ // integrity is trusted. A release fetched by tag may be a draft,
+ // which is visible only to push-access users and not world-readable
+ // even on a public repo, so a draft forces private confidentiality.
+ result = attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, result,
+ func(isPrivate bool) ifc.SecurityLabel {
+ return ifc.LabelRelease(isPrivate, release.GetDraft())
+ })
+ return result, nil, nil
},
)
}
@@ -2067,7 +2117,18 @@ func ListStarredRepositories(t translations.TranslationHelperFunc) inventory.Ser
return nil, nil, fmt.Errorf("failed to marshal starred repositories: %w", err)
}
- return utils.NewToolResultText(string(r)), nil, nil
+ result := utils.NewToolResultText(string(r))
+ // A starred-repository listing exposes repository data across many
+ // repos; reuse the multi-repo join shared with search_repositories
+ // (untrusted integrity; confidentiality private if any matched repo
+ // is private). Visibility is read directly from the response, so no
+ // extra API call is needed.
+ visibilities := make([]bool, 0, len(minimalRepos))
+ for _, mr := range minimalRepos {
+ visibilities = append(visibilities, mr.Private)
+ }
+ result = attachJoinedIFCLabel(ctx, deps, result, visibilities, ifc.LabelSearchIssues)
+ return result, nil, nil
},
)
}
@@ -2203,6 +2264,425 @@ func UnstarRepository(t translations.TranslationHelperFunc) inventory.ServerTool
)
}
+// maxBlameRanges caps the number of matching blame ranges considered for one response.
+const maxBlameRanges = 1000
+
+const blameCursorPrefix = "blame-range:"
+
+func encodeBlameCursor(offset int) string {
+ return base64.RawURLEncoding.EncodeToString(fmt.Appendf(nil, "%s%d", blameCursorPrefix, offset))
+}
+
+func decodeBlameCursor(cursor string) (int, error) {
+ if cursor == "" {
+ return 0, nil
+ }
+
+ decoded, err := base64.RawURLEncoding.DecodeString(cursor)
+ if err != nil {
+ return 0, fmt.Errorf("after cursor is invalid")
+ }
+
+ value := string(decoded)
+ if !strings.HasPrefix(value, blameCursorPrefix) {
+ return 0, fmt.Errorf("after cursor is invalid")
+ }
+
+ offset, err := strconv.Atoi(strings.TrimPrefix(value, blameCursorPrefix))
+ if err != nil || offset < 0 {
+ return 0, fmt.Errorf("after cursor is invalid")
+ }
+
+ return offset, nil
+}
+
+// BlameAuthor describes the author of a commit referenced by a BlameRange.
+type BlameAuthor struct {
+ Name string `json:"name"`
+ Email string `json:"email"`
+ Login *string `json:"login,omitempty"`
+ URL *string `json:"url,omitempty"`
+}
+
+// BlameCommit holds commit metadata shared by one or more blame ranges.
+type BlameCommit struct {
+ SHA string `json:"sha"`
+ MessageHeadline string `json:"message_headline"`
+ CommittedDate string `json:"committed_date"`
+ Author BlameAuthor `json:"author"`
+}
+
+// BlameRange is a contiguous run of lines attributed to a single commit.
+//
+// Age is the relative position of this range's commit among distinct commits
+// touching the file (0 = newest), not an absolute time delta. See:
+// https://docs.github.com/en/graphql/reference/objects#blamerange
+type BlameRange struct {
+ StartingLine int `json:"starting_line"`
+ EndingLine int `json:"ending_line"`
+ Age int `json:"age"`
+ CommitSHA string `json:"commit_sha"`
+}
+
+// BlameResult is the response payload returned by the get_file_blame tool.
+//
+// Commits is keyed by SHA. TotalRanges counts matching ranges before cursor
+// pagination or truncation. Truncated reports whether maxBlameRanges was hit.
+type BlameResult struct {
+ Repository string `json:"repository"`
+ Path string `json:"path"`
+ Ref string `json:"ref"`
+ Ranges []BlameRange `json:"ranges"`
+ Commits map[string]BlameCommit `json:"commits"`
+ PageInfo MinimalPageInfo `json:"pageInfo"`
+ TotalRanges int `json:"total_ranges"`
+ Truncated bool `json:"truncated,omitempty"`
+}
+
+// blameCommitFragment is the GraphQL selection for a Commit's blame data.
+type blameCommitFragment struct {
+ Blame struct {
+ Ranges []struct {
+ StartingLine githubv4.Int
+ EndingLine githubv4.Int
+ Age githubv4.Int
+ Commit struct {
+ OID githubv4.String
+ Message githubv4.String
+ CommittedDate githubv4.DateTime
+ Author struct {
+ Name githubv4.String
+ Email githubv4.String
+ User *struct {
+ Login githubv4.String
+ URL githubv4.String
+ }
+ }
+ }
+ }
+ } `graphql:"blame(path: $path)"`
+}
+
+// validateBlamePath rejects empty, leading-slash, traversal-laden, or
+// control-character paths before any network call is made.
+func validateBlamePath(p string) error {
+ if strings.TrimSpace(p) == "" {
+ return fmt.Errorf("path must not be empty")
+ }
+ if strings.HasPrefix(p, "/") {
+ return fmt.Errorf("path must be relative to the repository root (no leading '/')")
+ }
+ if slices.Contains(strings.Split(p, "/"), "..") {
+ return fmt.Errorf("path must not contain '..' segments")
+ }
+ for _, r := range p {
+ if r < 0x20 || r == 0x7f {
+ return fmt.Errorf("path must not contain control characters")
+ }
+ }
+ return nil
+}
+
+func GetFileBlame(t translations.TranslationHelperFunc) inventory.ServerTool {
+ st := NewTool(
+ ToolsetMetadataRepos,
+ mcp.Tool{
+ Name: "get_file_blame",
+ Description: t("TOOL_GET_FILE_BLAME_DESCRIPTION",
+ "Get git blame information for a file, showing the commit that last modified each line. "+
+ "Ranges share commit metadata via the top-level 'commits' map keyed by SHA. "+
+ "Use 'start_line'/'end_line' to restrict the result to a window of the file, and "+
+ "'perPage'/'after' to cursor-page through returned ranges. Matching ranges are capped at "+
+ "1000; when the cap is hit 'truncated' is set to true and 'total_ranges' reports the pre-cap match count.",
+ ),
+ Annotations: &mcp.ToolAnnotations{
+ Title: t("TOOL_GET_FILE_BLAME_USER_TITLE", "Get file blame information"),
+ ReadOnlyHint: true,
+ },
+ InputSchema: WithCursorPagination(&jsonschema.Schema{
+ Type: "object",
+ Properties: map[string]*jsonschema.Schema{
+ "owner": {
+ Type: "string",
+ Description: "Repository owner (username or organization)",
+ },
+ "repo": {
+ Type: "string",
+ Description: "Repository name",
+ },
+ "path": {
+ Type: "string",
+ Description: "Path to the file in the repository, relative to the repository root",
+ },
+ "ref": {
+ Type: "string",
+ Description: "Git reference (branch, tag, or commit SHA). Defaults to the repository's default branch (HEAD).",
+ },
+ "start_line": {
+ Type: "number",
+ Description: "Optional 1-based starting line of the window of interest. Only ranges overlapping [start_line, end_line] are returned, clamped to the window.",
+ Minimum: jsonschema.Ptr(1.0),
+ },
+ "end_line": {
+ Type: "number",
+ Description: "Optional 1-based ending line of the window of interest. Must be >= start_line when both are provided.",
+ Minimum: jsonschema.Ptr(1.0),
+ },
+ },
+ Required: []string{"owner", "repo", "path"},
+ }),
+ },
+ []scopes.Scope{scopes.Repo},
+ func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
+ owner, err := RequiredParam[string](args, "owner")
+ if err != nil {
+ return utils.NewToolResultError(err.Error()), nil, nil
+ }
+ repo, err := RequiredParam[string](args, "repo")
+ if err != nil {
+ return utils.NewToolResultError(err.Error()), nil, nil
+ }
+ path, err := RequiredParam[string](args, "path")
+ if err != nil {
+ return utils.NewToolResultError(err.Error()), nil, nil
+ }
+ if err := validateBlamePath(path); err != nil {
+ return utils.NewToolResultError(err.Error()), nil, nil
+ }
+ ref, err := OptionalParam[string](args, "ref")
+ if err != nil {
+ return utils.NewToolResultError(err.Error()), nil, nil
+ }
+ _, hasStartLine := args["start_line"]
+ startLine, err := OptionalIntParam(args, "start_line")
+ if err != nil {
+ return utils.NewToolResultError(err.Error()), nil, nil
+ }
+ if hasStartLine && startLine < 1 {
+ return utils.NewToolResultError("start_line must be omitted or >= 1"), nil, nil
+ }
+ _, hasEndLine := args["end_line"]
+ endLine, err := OptionalIntParam(args, "end_line")
+ if err != nil {
+ return utils.NewToolResultError(err.Error()), nil, nil
+ }
+ if hasEndLine && endLine < 1 {
+ return utils.NewToolResultError("end_line must be omitted or >= 1"), nil, nil
+ }
+ if hasStartLine && hasEndLine && endLine < startLine {
+ return utils.NewToolResultError("end_line must be >= start_line when both are provided"), nil, nil
+ }
+ if _, hasPage := args["page"]; hasPage {
+ return utils.NewToolResultError("This tool uses cursor-based pagination. Use the 'after' parameter with the 'endCursor' value from the previous response instead of 'page'."), nil, nil
+ }
+ pagination, err := OptionalCursorPaginationParams(args)
+ if err != nil {
+ return utils.NewToolResultError(err.Error()), nil, nil
+ }
+ if _, hasPerPage := args["perPage"]; hasPerPage {
+ perPage, err := OptionalIntParam(args, "perPage")
+ if err != nil {
+ return utils.NewToolResultError(err.Error()), nil, nil
+ }
+ if perPage < 1 || perPage > 100 {
+ return utils.NewToolResultError("perPage must be between 1 and 100 when provided"), nil, nil
+ }
+ pagination.PerPage = perPage
+ }
+ afterOffset, err := decodeBlameCursor(pagination.After)
+ if err != nil {
+ return utils.NewToolResultError(err.Error()), nil, nil
+ }
+
+ client, err := deps.GetGQLClient(ctx)
+ if err != nil {
+ return nil, nil, fmt.Errorf("failed to get GitHub GraphQL client: %w", err)
+ }
+
+ // Default to HEAD and fetch defaultBranchRef.name in the same query
+ // so the response can echo a readable ref.
+ refExpression := ref
+ if refExpression == "" {
+ refExpression = "HEAD"
+ }
+
+ var blameQuery struct {
+ Repository struct {
+ DefaultBranchRef struct {
+ Name githubv4.String
+ }
+ Object struct {
+ Typename githubv4.String `graphql:"__typename"`
+ Commit blameCommitFragment `graphql:"... on Commit"`
+ // Annotated tag targets are followed one level. Tag-of-tag
+ // chains are not followed and will return an error.
+ Tag struct {
+ Target struct {
+ Typename githubv4.String `graphql:"__typename"`
+ Commit blameCommitFragment `graphql:"... on Commit"`
+ }
+ } `graphql:"... on Tag"`
+ } `graphql:"object(expression: $ref)"`
+ } `graphql:"repository(owner: $owner, name: $repo)"`
+ }
+
+ vars := map[string]any{
+ "owner": githubv4.String(owner),
+ "repo": githubv4.String(repo),
+ "ref": githubv4.String(refExpression),
+ "path": githubv4.String(path),
+ }
+
+ if err := client.Query(ctx, &blameQuery, vars); err != nil {
+ return ghErrors.NewGitHubGraphQLErrorResponse(ctx,
+ fmt.Sprintf("failed to get blame for file: %s", path),
+ err,
+ ), nil, nil
+ }
+
+ // GitHub's Commit.blame field accepts only path, and Blame.ranges is
+ // not a connection, so cursor pagination is applied locally below.
+ // The ref must resolve to a commit, either directly or via an annotated tag.
+ objectTypename := string(blameQuery.Repository.Object.Typename)
+ if objectTypename == "" {
+ return utils.NewToolResultError(
+ fmt.Sprintf("ref %q was not found in %s/%s", refExpression, owner, repo),
+ ), nil, nil
+ }
+ blameCommit := &blameQuery.Repository.Object.Commit
+ if objectTypename == "Tag" {
+ targetTypename := string(blameQuery.Repository.Object.Tag.Target.Typename)
+ if targetTypename != "Commit" {
+ if targetTypename == "" {
+ targetTypename = "unknown"
+ }
+ return utils.NewToolResultError(
+ fmt.Sprintf("ref %q resolved to a tag in %s/%s, but the tag target did not resolve to a commit (resolved to %s)",
+ refExpression, owner, repo, targetTypename),
+ ), nil, nil
+ }
+ blameCommit = &blameQuery.Repository.Object.Tag.Target.Commit
+ } else if objectTypename != "Commit" {
+ return utils.NewToolResultError(
+ fmt.Sprintf("ref %q did not resolve to a commit in %s/%s (resolved to %s)",
+ refExpression, owner, repo, objectTypename),
+ ), nil, nil
+ }
+
+ // Echo the caller's ref, otherwise prefer the default branch name.
+ responseRef := ref
+ if responseRef == "" {
+ if name := string(blameQuery.Repository.DefaultBranchRef.Name); name != "" {
+ responseRef = name
+ } else {
+ responseRef = refExpression
+ }
+ }
+
+ rawRanges := blameCommit.Blame.Ranges
+ pageRanges := make([]BlameRange, 0, pagination.PerPage)
+ commits := make(map[string]BlameCommit)
+ totalRanges := 0
+ truncated := false
+
+ for _, r := range rawRanges {
+ start := int(r.StartingLine)
+ end := int(r.EndingLine)
+ if startLine > 0 && end < startLine {
+ continue
+ }
+ if endLine > 0 && start > endLine {
+ continue
+ }
+ if startLine > 0 && start < startLine {
+ start = startLine
+ }
+ if endLine > 0 && end > endLine {
+ end = endLine
+ }
+
+ matchIndex := totalRanges
+ totalRanges++
+ if matchIndex >= maxBlameRanges {
+ truncated = true
+ continue
+ }
+ if matchIndex < afterOffset || len(pageRanges) >= pagination.PerPage {
+ continue
+ }
+
+ blameRange := BlameRange{
+ StartingLine: start,
+ EndingLine: end,
+ Age: int(r.Age),
+ CommitSHA: string(r.Commit.OID),
+ }
+ pageRanges = append(pageRanges, blameRange)
+
+ sha := string(r.Commit.OID)
+ if _, seen := commits[sha]; seen {
+ continue
+ }
+ headline := string(r.Commit.Message)
+ if idx := strings.IndexByte(headline, '\n'); idx >= 0 {
+ headline = headline[:idx]
+ }
+ headline = strings.TrimRight(headline, " \t\r")
+ bc := BlameCommit{
+ SHA: sha,
+ MessageHeadline: headline,
+ CommittedDate: r.Commit.CommittedDate.Format("2006-01-02T15:04:05Z"),
+ Author: BlameAuthor{
+ Name: string(r.Commit.Author.Name),
+ Email: string(r.Commit.Author.Email),
+ },
+ }
+ if r.Commit.Author.User != nil {
+ login := string(r.Commit.Author.User.Login)
+ url := string(r.Commit.Author.User.URL)
+ bc.Author.Login = &login
+ bc.Author.URL = &url
+ }
+ commits[sha] = bc
+ }
+
+ cappedRanges := min(totalRanges, maxBlameRanges)
+ consumedRanges := min(afterOffset+len(pageRanges), cappedRanges)
+ pageInfo := MinimalPageInfo{
+ HasNextPage: consumedRanges < cappedRanges,
+ HasPreviousPage: afterOffset > 0,
+ }
+ if len(pageRanges) > 0 {
+ pageInfo.StartCursor = encodeBlameCursor(afterOffset)
+ pageInfo.EndCursor = encodeBlameCursor(consumedRanges)
+ }
+
+ result := BlameResult{
+ Repository: fmt.Sprintf("%s/%s", owner, repo),
+ Path: path,
+ Ref: responseRef,
+ Ranges: pageRanges,
+ Commits: commits,
+ PageInfo: pageInfo,
+ TotalRanges: totalRanges,
+ Truncated: truncated,
+ }
+ if result.Ranges == nil {
+ result.Ranges = []BlameRange{}
+ }
+
+ payload, err := json.Marshal(result)
+ if err != nil {
+ return nil, nil, fmt.Errorf("failed to marshal response: %w", err)
+ }
+
+ return utils.NewToolResultText(string(payload)), nil, nil
+ },
+ )
+ st.FeatureFlagEnable = FeatureFlagFileBlame
+ return st
+}
+
// ListRepositoryCollaborators creates a tool to list collaborators of a GitHub repository.
func ListRepositoryCollaborators(t translations.TranslationHelperFunc) inventory.ServerTool {
return NewTool(
@@ -2306,7 +2786,13 @@ func ListRepositoryCollaborators(t translations.TranslationHelperFunc) inventory
"lastPage": resp.LastPage,
}
- return MarshalledTextResult(response), nil, nil
+ callResult := MarshalledTextResult(response)
+ // The collaborator roster is GitHub-maintained membership data
+ // (trusted, not attacker-authored). Listing collaborators requires
+ // push access, so the roster is never world-readable — not even on
+ // a public repo — hence always private confidentiality.
+ callResult = attachStaticIFCLabel(ctx, deps, callResult, ifc.LabelCollaboratorRoster())
+ return callResult, nil, nil
},
)
}
diff --git a/pkg/github/repositories_test.go b/pkg/github/repositories_test.go
index 03535f1d26..8b0b196a63 100644
--- a/pkg/github/repositories_test.go
+++ b/pkg/github/repositories_test.go
@@ -10,6 +10,7 @@ import (
"testing"
"time"
+ "github.com/github/github-mcp-server/internal/githubv4mock"
"github.com/github/github-mcp-server/internal/toolsnaps"
"github.com/github/github-mcp-server/pkg/raw"
"github.com/github/github-mcp-server/pkg/translations"
@@ -17,6 +18,7 @@ import (
"github.com/google/go-github/v87/github"
"github.com/google/jsonschema-go/jsonschema"
"github.com/modelcontextprotocol/go-sdk/mcp"
+ "github.com/shurcooL/githubv4"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -620,6 +622,127 @@ func Test_GetFileContents_IFC_InsidersMode(t *testing.T) {
})
}
+// Test_GetCommit_IFC_FeatureFlag verifies that the IFC security label is only
+// attached to get_commit results when the ifc_labels feature flag is enabled,
+// and that the label content matches the commit-contents rule (untrusted on
+// public repos, trusted on private). It also confirms the label is omitted
+// when the repository visibility lookup fails, so the result is never
+// misclassified. get_commit is representative of every tool wired through the
+// shared attachRepoVisibilityIFCLabel helper.
+func Test_GetCommit_IFC_FeatureFlag(t *testing.T) {
+ t.Parallel()
+
+ serverTool := GetCommit(translations.NullTranslationHelper)
+
+ mockCommit := &github.RepositoryCommit{
+ SHA: github.Ptr("abc123def456"),
+ Commit: &github.Commit{Message: github.Ptr("First commit")},
+ HTMLURL: github.Ptr("https://github.com/owner/repo/commit/abc123def456"),
+ }
+
+ makeMockClient := func(isPrivate bool) *http.Client {
+ return MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
+ GetReposCommitsByOwnerByRepoByRef: mockResponse(t, http.StatusOK, mockCommit),
+ GetReposByOwnerByRepo: mockResponse(t, http.StatusOK, map[string]any{
+ "name": "repo",
+ "private": isPrivate,
+ }),
+ })
+ }
+
+ reqParams := map[string]any{
+ "owner": "owner",
+ "repo": "repo",
+ "sha": "abc123def456",
+ }
+
+ t.Run("feature flag disabled omits ifc label from result meta", func(t *testing.T) {
+ deps := BaseDeps{
+ Client: mustNewGHClient(t, makeMockClient(false)),
+ }
+ handler := serverTool.Handler(deps)
+
+ request := createMCPRequest(reqParams)
+ result, err := handler(ContextWithDeps(context.Background(), deps), &request)
+ require.NoError(t, err)
+ require.False(t, result.IsError)
+
+ assert.Nil(t, result.Meta, "result meta should be nil when IFC labels are disabled")
+ })
+
+ t.Run("feature flag enabled on public repo emits public untrusted label", func(t *testing.T) {
+ deps := BaseDeps{
+ Client: mustNewGHClient(t, makeMockClient(false)),
+ featureChecker: featureCheckerFor(FeatureFlagIFCLabels),
+ }
+ handler := serverTool.Handler(deps)
+
+ request := createMCPRequest(reqParams)
+ result, err := handler(ContextWithDeps(context.Background(), deps), &request)
+ require.NoError(t, err)
+ require.False(t, result.IsError)
+
+ require.NotNil(t, result.Meta)
+ ifcLabel, ok := result.Meta["ifc"]
+ require.True(t, ok, "result meta should contain ifc key")
+
+ ifcJSON, err := json.Marshal(ifcLabel)
+ require.NoError(t, err)
+ var ifcMap map[string]any
+ require.NoError(t, json.Unmarshal(ifcJSON, &ifcMap))
+
+ assert.Equal(t, "untrusted", ifcMap["integrity"])
+ assert.Equal(t, "public", ifcMap["confidentiality"])
+ })
+
+ t.Run("feature flag enabled on private repo emits private trusted label", func(t *testing.T) {
+ deps := BaseDeps{
+ Client: mustNewGHClient(t, makeMockClient(true)),
+ featureChecker: featureCheckerFor(FeatureFlagIFCLabels),
+ }
+ handler := serverTool.Handler(deps)
+
+ request := createMCPRequest(reqParams)
+ result, err := handler(ContextWithDeps(context.Background(), deps), &request)
+ require.NoError(t, err)
+ require.False(t, result.IsError)
+
+ require.NotNil(t, result.Meta)
+ ifcLabel, ok := result.Meta["ifc"]
+ require.True(t, ok, "result meta should contain ifc key")
+
+ ifcJSON, err := json.Marshal(ifcLabel)
+ require.NoError(t, err)
+ var ifcMap map[string]any
+ require.NoError(t, json.Unmarshal(ifcJSON, &ifcMap))
+
+ assert.Equal(t, "trusted", ifcMap["integrity"])
+ assert.Equal(t, "private", ifcMap["confidentiality"])
+ })
+
+ t.Run("feature flag enabled skips ifc label when visibility lookup fails", func(t *testing.T) {
+ mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
+ GetReposCommitsByOwnerByRepoByRef: mockResponse(t, http.StatusOK, mockCommit),
+ GetReposByOwnerByRepo: mockResponse(t, http.StatusInternalServerError, "boom"),
+ })
+ deps := BaseDeps{
+ Client: mustNewGHClient(t, mockedClient),
+ featureChecker: featureCheckerFor(FeatureFlagIFCLabels),
+ }
+ handler := serverTool.Handler(deps)
+
+ request := createMCPRequest(reqParams)
+ result, err := handler(ContextWithDeps(context.Background(), deps), &request)
+ require.NoError(t, err)
+ require.False(t, result.IsError, "tool call should still succeed when visibility lookup fails")
+
+ if result.Meta != nil {
+ _, hasIFC := result.Meta["ifc"]
+ assert.False(t, hasIFC, "ifc label should be omitted when visibility lookup fails")
+ }
+ })
+}
+
func Test_ForkRepository(t *testing.T) {
// Verify tool definition once
serverTool := ForkRepository(translations.NullTranslationHelper)
@@ -1028,6 +1151,120 @@ func Test_GetCommit(t *testing.T) {
}
}
+func Test_GetCommit_Detail(t *testing.T) {
+ mockCommit := &github.RepositoryCommit{
+ SHA: github.Ptr("abc123def456"),
+ HTMLURL: github.Ptr("https://github.com/owner/repo/commit/abc123def456"),
+ Commit: &github.Commit{
+ Message: github.Ptr("First commit"),
+ },
+ Stats: &github.CommitStats{
+ Additions: github.Ptr(10),
+ Deletions: github.Ptr(2),
+ Total: github.Ptr(12),
+ },
+ Files: []*github.CommitFile{
+ {
+ Filename: github.Ptr("file1.go"),
+ Status: github.Ptr("modified"),
+ Additions: github.Ptr(10),
+ Deletions: github.Ptr(2),
+ Changes: github.Ptr(12),
+ Patch: github.Ptr("@@ -1,2 +1,10 @@\n+new line"),
+ },
+ },
+ }
+
+ cases := []struct {
+ name string
+ args map[string]any
+ expectFiles bool
+ expectStats bool
+ expectPatch bool
+ expectError string
+ }{
+ {
+ name: "default returns stats",
+ args: map[string]any{"owner": "owner", "repo": "repo", "sha": "abc123def456"},
+ expectFiles: true,
+ expectStats: true,
+ expectPatch: false,
+ },
+ {
+ name: "detail=none omits stats and files",
+ args: map[string]any{"owner": "owner", "repo": "repo", "sha": "abc123def456", "detail": "none"},
+ expectFiles: false,
+ expectStats: false,
+ expectPatch: false,
+ },
+ {
+ name: "detail=stats returns metadata without patch",
+ args: map[string]any{"owner": "owner", "repo": "repo", "sha": "abc123def456", "detail": "stats"},
+ expectFiles: true,
+ expectStats: true,
+ expectPatch: false,
+ },
+ {
+ name: "detail=full_patch includes patch text",
+ args: map[string]any{"owner": "owner", "repo": "repo", "sha": "abc123def456", "detail": "full_patch"},
+ expectFiles: true,
+ expectStats: true,
+ expectPatch: true,
+ },
+ {
+ name: "invalid detail value is rejected",
+ args: map[string]any{"owner": "owner", "repo": "repo", "sha": "abc123def456", "detail": "everything"},
+ expectError: `invalid detail "everything"`,
+ },
+ }
+
+ serverTool := GetCommit(translations.NullTranslationHelper)
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
+ GetReposCommitsByOwnerByRepoByRef: mockResponse(t, http.StatusOK, mockCommit),
+ })
+ client := mustNewGHClient(t, mockedClient)
+ deps := BaseDeps{Client: client}
+ handler := serverTool.Handler(deps)
+
+ request := createMCPRequest(tc.args)
+ result, err := handler(ContextWithDeps(context.Background(), deps), &request)
+ require.NoError(t, err)
+
+ if tc.expectError != "" {
+ require.True(t, result.IsError)
+ assert.Contains(t, getErrorResult(t, result).Text, tc.expectError)
+ return
+ }
+ require.False(t, result.IsError)
+
+ var returned MinimalCommit
+ require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &returned))
+
+ if tc.expectStats {
+ require.NotNil(t, returned.Stats)
+ assert.Equal(t, 12, returned.Stats.Total)
+ } else {
+ assert.Nil(t, returned.Stats)
+ }
+
+ if tc.expectFiles {
+ require.Len(t, returned.Files, 1)
+ assert.Equal(t, "file1.go", returned.Files[0].Filename)
+ if tc.expectPatch {
+ assert.Equal(t, "@@ -1,2 +1,10 @@\n+new line", returned.Files[0].Patch)
+ } else {
+ assert.Empty(t, returned.Files[0].Patch)
+ }
+ } else {
+ assert.Empty(t, returned.Files)
+ }
+ })
+ }
+}
+
func Test_ListCommits(t *testing.T) {
// Verify tool definition once
serverTool := ListCommits(translations.NullTranslationHelper)
@@ -3603,6 +3840,107 @@ func Test_GetReleaseByTag(t *testing.T) {
}
}
+// Test_GetReleaseByTag_IFC_FeatureFlag verifies the IFC label on
+// get_release_by_tag. The label is only present when the ifc_labels flag is
+// enabled, and confidentiality is public only for a non-draft release on a
+// public repo. A draft release is visible only to push-access users, so even
+// on a public repo it must be labeled private. Guards against the same
+// under-classification fixed for repository security advisories.
+func Test_GetReleaseByTag_IFC_FeatureFlag(t *testing.T) {
+ t.Parallel()
+
+ serverTool := GetReleaseByTag(translations.NullTranslationHelper)
+
+ makeRelease := func(draft bool) *github.RepositoryRelease {
+ return &github.RepositoryRelease{
+ ID: github.Ptr(int64(1)),
+ TagName: github.Ptr("v1.0.0"),
+ Name: github.Ptr("v1.0.0"),
+ Draft: github.Ptr(draft),
+ }
+ }
+
+ makeMockClient := func(isPrivate bool, release *github.RepositoryRelease) *http.Client {
+ return MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
+ GetReposReleasesTagsByOwnerByRepoByTag: mockResponse(t, http.StatusOK, release),
+ GetReposByOwnerByRepo: mockResponse(t, http.StatusOK, map[string]any{
+ "name": "repo",
+ "private": isPrivate,
+ }),
+ })
+ }
+
+ reqParams := map[string]any{"owner": "owner", "repo": "repo", "tag": "v1.0.0"}
+
+ readIFC := func(t *testing.T, result *mcp.CallToolResult) (map[string]any, bool) {
+ t.Helper()
+ if result.Meta == nil {
+ return nil, false
+ }
+ label, ok := result.Meta["ifc"]
+ if !ok {
+ return nil, false
+ }
+ labelJSON, err := json.Marshal(label)
+ require.NoError(t, err)
+ var labelMap map[string]any
+ require.NoError(t, json.Unmarshal(labelJSON, &labelMap))
+ return labelMap, true
+ }
+
+ t.Run("feature flag disabled omits ifc label", func(t *testing.T) {
+ t.Parallel()
+ deps := BaseDeps{Client: mustNewGHClient(t, makeMockClient(false, makeRelease(false)))}
+ handler := serverTool.Handler(deps)
+
+ request := createMCPRequest(reqParams)
+ result, err := handler(ContextWithDeps(context.Background(), deps), &request)
+ require.NoError(t, err)
+ require.False(t, result.IsError)
+ assert.Nil(t, result.Meta)
+ })
+
+ t.Run("public repo with published release is public", func(t *testing.T) {
+ t.Parallel()
+ deps := BaseDeps{
+ Client: mustNewGHClient(t, makeMockClient(false, makeRelease(false))),
+ featureChecker: featureCheckerFor(FeatureFlagIFCLabels),
+ }
+ handler := serverTool.Handler(deps)
+
+ request := createMCPRequest(reqParams)
+ result, err := handler(ContextWithDeps(context.Background(), deps), &request)
+ require.NoError(t, err)
+ require.False(t, result.IsError)
+
+ label, ok := readIFC(t, result)
+ require.True(t, ok)
+ assert.Equal(t, "trusted", label["integrity"])
+ assert.Equal(t, "public", label["confidentiality"])
+ })
+
+ t.Run("public repo with draft release is private", func(t *testing.T) {
+ t.Parallel()
+ // Reviewer-class scenario: a draft release on a public repo is not
+ // world-readable, so the label must not be public.
+ deps := BaseDeps{
+ Client: mustNewGHClient(t, makeMockClient(false, makeRelease(true))),
+ featureChecker: featureCheckerFor(FeatureFlagIFCLabels),
+ }
+ handler := serverTool.Handler(deps)
+
+ request := createMCPRequest(reqParams)
+ result, err := handler(ContextWithDeps(context.Background(), deps), &request)
+ require.NoError(t, err)
+ require.False(t, result.IsError)
+
+ label, ok := readIFC(t, result)
+ require.True(t, ok)
+ assert.Equal(t, "trusted", label["integrity"])
+ assert.Equal(t, "private", label["confidentiality"], "draft release on public repo must be private")
+ })
+}
+
func Test_looksLikeSHA(t *testing.T) {
tests := []struct {
name string
@@ -4368,6 +4706,670 @@ func Test_UnstarRepository(t *testing.T) {
})
}
}
+func Test_GetFileBlame(t *testing.T) {
+ // Verify tool definition once
+ serverTool := GetFileBlame(translations.NullTranslationHelper)
+ tool := serverTool.Tool
+ require.NoError(t, toolsnaps.Test(tool.Name, tool))
+
+ // get_file_blame is gated so it is not advertised unless the feature flag
+ // (or insiders mode) opts it in.
+ assert.Equal(t, FeatureFlagFileBlame, serverTool.FeatureFlagEnable, "get_file_blame must be gated behind the file_blame feature flag")
+
+ schema, ok := tool.InputSchema.(*jsonschema.Schema)
+ require.True(t, ok, "InputSchema should be *jsonschema.Schema")
+ assert.Equal(t, "get_file_blame", tool.Name)
+ assert.NotEmpty(t, tool.Description)
+ for _, key := range []string{"owner", "repo", "path", "ref", "start_line", "end_line", "perPage", "after"} {
+ assert.Contains(t, schema.Properties, key, "schema missing property %q", key)
+ }
+ assert.NotContains(t, schema.Properties, "page")
+ assert.ElementsMatch(t, schema.Required, []string{"owner", "repo", "path"})
+ require.NotNil(t, tool.Annotations)
+ assert.True(t, tool.Annotations.ReadOnlyHint, "blame is read-only")
+
+ // blameQueryShape is the GraphQL query shape used by all
+ // network-touching subtests below. Defined once so changes to the wire
+ // schema are made in a single place.
+ type blameQueryShape = struct {
+ Repository struct {
+ DefaultBranchRef struct {
+ Name githubv4.String
+ }
+ Object struct {
+ Typename githubv4.String `graphql:"__typename"`
+ Commit blameCommitFragment `graphql:"... on Commit"`
+ Tag struct {
+ Target struct {
+ Typename githubv4.String `graphql:"__typename"`
+ Commit blameCommitFragment `graphql:"... on Commit"`
+ }
+ } `graphql:"... on Tag"`
+ } `graphql:"object(expression: $ref)"`
+ } `graphql:"repository(owner: $owner, name: $repo)"`
+ }
+
+ makeBlameVars := func(owner, repo, ref, path string) map[string]any {
+ return map[string]any{
+ "owner": githubv4.String(owner),
+ "repo": githubv4.String(repo),
+ "ref": githubv4.String(ref),
+ "path": githubv4.String(path),
+ }
+ }
+
+ tests := []struct {
+ name string
+ mockedClient *http.Client
+ requestArgs map[string]any
+ expectError bool
+ expectedErrMsg string
+ validateResponse func(t *testing.T, result string)
+ }{
+ {
+ name: "successful blame using default branch (HEAD)",
+ mockedClient: githubv4mock.NewMockedHTTPClient(
+ githubv4mock.NewQueryMatcher(
+ blameQueryShape{},
+ makeBlameVars("testowner", "testrepo", "HEAD", "README.md"),
+ githubv4mock.DataResponse(map[string]any{
+ "repository": map[string]any{
+ "defaultBranchRef": map[string]any{"name": "main"},
+ "object": map[string]any{
+ "__typename": "Commit",
+ "blame": map[string]any{
+ "ranges": []map[string]any{
+ {
+ "startingLine": 1, "endingLine": 5, "age": 2,
+ "commit": map[string]any{
+ "oid": "abc123def456",
+ "message": "Initial commit\n\nLong body that should not appear in the response.",
+ "committedDate": "2024-01-01T12:00:00Z",
+ "author": map[string]any{
+ "name": "John Doe", "email": "john@example.com",
+ "user": map[string]any{"login": "johndoe", "url": "https://github.com/johndoe"},
+ },
+ },
+ },
+ {
+ // Same commit as the first range -> must be deduplicated.
+ "startingLine": 6, "endingLine": 7, "age": 2,
+ "commit": map[string]any{
+ "oid": "abc123def456",
+ "message": "Initial commit\n\nLong body that should not appear in the response.",
+ "committedDate": "2024-01-01T12:00:00Z",
+ "author": map[string]any{
+ "name": "John Doe", "email": "john@example.com",
+ "user": map[string]any{"login": "johndoe", "url": "https://github.com/johndoe"},
+ },
+ },
+ },
+ {
+ "startingLine": 8, "endingLine": 10, "age": 1,
+ "commit": map[string]any{
+ "oid": "def456ghi789",
+ "message": "Update README",
+ "committedDate": "2024-01-02T15:30:00Z",
+ "author": map[string]any{
+ "name": "Jane Smith", "email": "jane@example.com",
+ "user": map[string]any{"login": "janesmith", "url": "https://github.com/janesmith"},
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ }),
+ ),
+ ),
+ requestArgs: map[string]any{
+ "owner": "testowner",
+ "repo": "testrepo",
+ "path": "README.md",
+ },
+ validateResponse: func(t *testing.T, result string) {
+ var br BlameResult
+ require.NoError(t, json.Unmarshal([]byte(result), &br))
+ assert.Equal(t, "testowner/testrepo", br.Repository)
+ assert.Equal(t, "README.md", br.Path)
+ assert.Equal(t, "main", br.Ref, "ref should resolve to default branch name")
+ assert.False(t, br.Truncated)
+ assert.Equal(t, 3, br.TotalRanges)
+ assert.False(t, br.PageInfo.HasNextPage)
+ assert.False(t, br.PageInfo.HasPreviousPage)
+ assert.NotEmpty(t, br.PageInfo.StartCursor)
+ assert.NotEmpty(t, br.PageInfo.EndCursor)
+ require.Len(t, br.Ranges, 3)
+ // Commits map is deduplicated.
+ require.Len(t, br.Commits, 2)
+ require.Contains(t, br.Commits, "abc123def456")
+ require.Contains(t, br.Commits, "def456ghi789")
+ // Multi-line message must be reduced to its headline.
+ assert.Equal(t, "Initial commit", br.Commits["abc123def456"].MessageHeadline)
+ assert.NotContains(t, result, "Long body that should not appear")
+ // Login/URL pointers populated.
+ require.NotNil(t, br.Commits["abc123def456"].Author.Login)
+ assert.Equal(t, "johndoe", *br.Commits["abc123def456"].Author.Login)
+ },
+ },
+ {
+ name: "successful blame with explicit ref",
+ mockedClient: githubv4mock.NewMockedHTTPClient(
+ githubv4mock.NewQueryMatcher(
+ blameQueryShape{},
+ makeBlameVars("testowner", "testrepo", "feature-branch", "src/main.go"),
+ githubv4mock.DataResponse(map[string]any{
+ "repository": map[string]any{
+ "defaultBranchRef": map[string]any{"name": "main"},
+ "object": map[string]any{
+ "__typename": "Commit",
+ "blame": map[string]any{
+ "ranges": []map[string]any{
+ {
+ "startingLine": 1, "endingLine": 3, "age": 1,
+ "commit": map[string]any{
+ "oid": "xyz789abc123",
+ "message": "Add main function",
+ "committedDate": "2024-01-03T10:00:00Z",
+ "author": map[string]any{
+ "name": "Bob Developer", "email": "bob@example.com",
+ "user": nil,
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ }),
+ ),
+ ),
+ requestArgs: map[string]any{
+ "owner": "testowner",
+ "repo": "testrepo",
+ "path": "src/main.go",
+ "ref": "feature-branch",
+ },
+ validateResponse: func(t *testing.T, result string) {
+ var br BlameResult
+ require.NoError(t, json.Unmarshal([]byte(result), &br))
+ assert.Equal(t, "feature-branch", br.Ref, "explicit ref echoed back")
+ require.Len(t, br.Ranges, 1)
+ require.Contains(t, br.Commits, "xyz789abc123")
+ assert.Nil(t, br.Commits["xyz789abc123"].Author.Login, "anonymous author has no login")
+ },
+ },
+ {
+ name: "successful blame with annotated tag ref",
+ mockedClient: githubv4mock.NewMockedHTTPClient(
+ githubv4mock.NewQueryMatcher(
+ blameQueryShape{},
+ makeBlameVars("testowner", "testrepo", "v1.0.0", "src/tagged.go"),
+ githubv4mock.DataResponse(map[string]any{
+ "repository": map[string]any{
+ "defaultBranchRef": map[string]any{"name": "main"},
+ "object": map[string]any{
+ "__typename": "Tag",
+ "target": map[string]any{
+ "__typename": "Commit",
+ "blame": map[string]any{
+ "ranges": []map[string]any{
+ {
+ "startingLine": 1, "endingLine": 2, "age": 1,
+ "commit": map[string]any{
+ "oid": "taggedcommit123",
+ "message": "Tagged release commit",
+ "committedDate": "2024-01-04T10:00:00Z",
+ "author": map[string]any{"name": "Tag Author", "email": "tag@example.com", "user": nil},
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ }),
+ ),
+ ),
+ requestArgs: map[string]any{
+ "owner": "testowner",
+ "repo": "testrepo",
+ "path": "src/tagged.go",
+ "ref": "v1.0.0",
+ },
+ validateResponse: func(t *testing.T, result string) {
+ var br BlameResult
+ require.NoError(t, json.Unmarshal([]byte(result), &br))
+ assert.Equal(t, "v1.0.0", br.Ref, "explicit annotated tag ref echoed back")
+ require.Len(t, br.Ranges, 1)
+ assert.Equal(t, "taggedcommit123", br.Ranges[0].CommitSHA)
+ require.Contains(t, br.Commits, "taggedcommit123")
+ assert.Equal(t, "Tagged release commit", br.Commits["taggedcommit123"].MessageHeadline,
+ "commit metadata threads through the Tag.Target.Commit path")
+ assert.Equal(t, "Tag Author", br.Commits["taggedcommit123"].Author.Name)
+ },
+ },
+ {
+ name: "empty blame ranges",
+ mockedClient: githubv4mock.NewMockedHTTPClient(
+ githubv4mock.NewQueryMatcher(
+ blameQueryShape{},
+ makeBlameVars("testowner", "testrepo", "HEAD", "EMPTY.md"),
+ githubv4mock.DataResponse(map[string]any{
+ "repository": map[string]any{
+ "defaultBranchRef": map[string]any{"name": "main"},
+ "object": map[string]any{
+ "__typename": "Commit",
+ "blame": map[string]any{"ranges": []map[string]any{}},
+ },
+ },
+ }),
+ ),
+ ),
+ requestArgs: map[string]any{
+ "owner": "testowner",
+ "repo": "testrepo",
+ "path": "EMPTY.md",
+ },
+ validateResponse: func(t *testing.T, result string) {
+ var br BlameResult
+ require.NoError(t, json.Unmarshal([]byte(result), &br))
+ assert.Equal(t, 0, br.TotalRanges)
+ assert.Empty(t, br.Ranges)
+ assert.Empty(t, br.Commits)
+ assert.False(t, br.PageInfo.HasNextPage)
+ assert.False(t, br.PageInfo.HasPreviousPage)
+ assert.False(t, br.Truncated)
+ // Ranges should marshal as an empty array, not null.
+ assert.Contains(t, result, `"ranges":[]`)
+ },
+ },
+ {
+ name: "ref resolves to non-commit object",
+ mockedClient: githubv4mock.NewMockedHTTPClient(
+ githubv4mock.NewQueryMatcher(
+ blameQueryShape{},
+ makeBlameVars("testowner", "testrepo", "main", "docs"),
+ githubv4mock.DataResponse(map[string]any{
+ "repository": map[string]any{
+ "defaultBranchRef": map[string]any{"name": "main"},
+ "object": map[string]any{
+ "__typename": "Tree",
+ "blame": map[string]any{"ranges": []map[string]any{}},
+ },
+ },
+ }),
+ ),
+ ),
+ requestArgs: map[string]any{
+ "owner": "testowner",
+ "repo": "testrepo",
+ "path": "docs",
+ "ref": "main",
+ },
+ expectError: true,
+ expectedErrMsg: "did not resolve to a commit",
+ },
+ {
+ name: "ref not found",
+ mockedClient: githubv4mock.NewMockedHTTPClient(
+ githubv4mock.NewQueryMatcher(
+ blameQueryShape{},
+ makeBlameVars("testowner", "testrepo", "no-such-ref", "README.md"),
+ githubv4mock.DataResponse(map[string]any{
+ "repository": map[string]any{
+ "defaultBranchRef": map[string]any{"name": "main"},
+ "object": nil,
+ },
+ }),
+ ),
+ ),
+ requestArgs: map[string]any{
+ "owner": "testowner",
+ "repo": "testrepo",
+ "path": "README.md",
+ "ref": "no-such-ref",
+ },
+ expectError: true,
+ expectedErrMsg: "was not found",
+ },
+ {
+ name: "annotated tag target is not commit",
+ mockedClient: githubv4mock.NewMockedHTTPClient(
+ githubv4mock.NewQueryMatcher(
+ blameQueryShape{},
+ makeBlameVars("testowner", "testrepo", "tree-tag", "README.md"),
+ githubv4mock.DataResponse(map[string]any{
+ "repository": map[string]any{
+ "defaultBranchRef": map[string]any{"name": "main"},
+ "object": map[string]any{
+ "__typename": "Tag",
+ "target": map[string]any{"__typename": "Tree"},
+ },
+ },
+ }),
+ ),
+ ),
+ requestArgs: map[string]any{
+ "owner": "testowner",
+ "repo": "testrepo",
+ "path": "README.md",
+ "ref": "tree-tag",
+ },
+ expectError: true,
+ expectedErrMsg: "tag target did not resolve to a commit",
+ },
+ {
+ name: "line-range filter clamps and drops ranges",
+ mockedClient: githubv4mock.NewMockedHTTPClient(
+ githubv4mock.NewQueryMatcher(
+ blameQueryShape{},
+ makeBlameVars("testowner", "testrepo", "HEAD", "src/big.go"),
+ githubv4mock.DataResponse(map[string]any{
+ "repository": map[string]any{
+ "defaultBranchRef": map[string]any{"name": "main"},
+ "object": map[string]any{
+ "__typename": "Commit",
+ "blame": map[string]any{
+ "ranges": []map[string]any{
+ {
+ "startingLine": 1, "endingLine": 5, "age": 1,
+ "commit": map[string]any{
+ "oid": "sha-A", "message": "A", "committedDate": "2024-01-01T00:00:00Z",
+ "author": map[string]any{"name": "a", "email": "a@x", "user": nil},
+ },
+ },
+ {
+ "startingLine": 6, "endingLine": 12, "age": 1,
+ "commit": map[string]any{
+ "oid": "sha-B", "message": "B", "committedDate": "2024-01-01T00:00:00Z",
+ "author": map[string]any{"name": "b", "email": "b@x", "user": nil},
+ },
+ },
+ {
+ "startingLine": 13, "endingLine": 20, "age": 1,
+ "commit": map[string]any{
+ "oid": "sha-C", "message": "C", "committedDate": "2024-01-01T00:00:00Z",
+ "author": map[string]any{"name": "c", "email": "c@x", "user": nil},
+ },
+ },
+ },
+ },
+ },
+ },
+ }),
+ ),
+ ),
+ requestArgs: map[string]any{
+ "owner": "testowner",
+ "repo": "testrepo",
+ "path": "src/big.go",
+ "start_line": float64(8),
+ "end_line": float64(15),
+ },
+ validateResponse: func(t *testing.T, result string) {
+ var br BlameResult
+ require.NoError(t, json.Unmarshal([]byte(result), &br))
+ // First range (1-5) is dropped; middle clamped to 8-12;
+ // last clamped to 13-15.
+ require.Len(t, br.Ranges, 2)
+ assert.Equal(t, 8, br.Ranges[0].StartingLine)
+ assert.Equal(t, 12, br.Ranges[0].EndingLine)
+ assert.Equal(t, "sha-B", br.Ranges[0].CommitSHA)
+ assert.Equal(t, 13, br.Ranges[1].StartingLine)
+ assert.Equal(t, 15, br.Ranges[1].EndingLine)
+ assert.Equal(t, "sha-C", br.Ranges[1].CommitSHA)
+ assert.NotContains(t, br.Commits, "sha-A", "filtered-out commit must not appear")
+ },
+ },
+ {
+ name: "cursor pagination returns requested page",
+ mockedClient: githubv4mock.NewMockedHTTPClient(
+ githubv4mock.NewQueryMatcher(
+ blameQueryShape{},
+ makeBlameVars("testowner", "testrepo", "HEAD", "src/paged.go"),
+ githubv4mock.DataResponse(map[string]any{
+ "repository": map[string]any{
+ "defaultBranchRef": map[string]any{"name": "main"},
+ "object": map[string]any{
+ "__typename": "Commit",
+ "blame": map[string]any{
+ "ranges": []map[string]any{
+ {
+ "startingLine": 1, "endingLine": 1, "age": 1,
+ "commit": map[string]any{
+ "oid": "sha-A", "message": "A", "committedDate": "2024-01-01T00:00:00Z",
+ "author": map[string]any{"name": "a", "email": "a@x", "user": nil},
+ },
+ },
+ {
+ "startingLine": 2, "endingLine": 2, "age": 1,
+ "commit": map[string]any{
+ "oid": "sha-B", "message": "B", "committedDate": "2024-01-01T00:00:00Z",
+ "author": map[string]any{"name": "b", "email": "b@x", "user": nil},
+ },
+ },
+ {
+ "startingLine": 3, "endingLine": 3, "age": 1,
+ "commit": map[string]any{
+ "oid": "sha-C", "message": "C", "committedDate": "2024-01-01T00:00:00Z",
+ "author": map[string]any{"name": "c", "email": "c@x", "user": nil},
+ },
+ },
+ },
+ },
+ },
+ },
+ }),
+ ),
+ ),
+ requestArgs: map[string]any{
+ "owner": "testowner",
+ "repo": "testrepo",
+ "path": "src/paged.go",
+ "perPage": float64(1),
+ "after": encodeBlameCursor(1),
+ },
+ validateResponse: func(t *testing.T, result string) {
+ var br BlameResult
+ require.NoError(t, json.Unmarshal([]byte(result), &br))
+ assert.Equal(t, 3, br.TotalRanges)
+ require.Len(t, br.Ranges, 1)
+ assert.Equal(t, "sha-B", br.Ranges[0].CommitSHA)
+ require.Len(t, br.Commits, 1)
+ require.Contains(t, br.Commits, "sha-B")
+ assert.True(t, br.PageInfo.HasNextPage)
+ assert.True(t, br.PageInfo.HasPreviousPage)
+ assert.Equal(t, encodeBlameCursor(1), br.PageInfo.StartCursor)
+ assert.Equal(t, encodeBlameCursor(2), br.PageInfo.EndCursor)
+ },
+ },
+ {
+ name: "GraphQL error is surfaced",
+ mockedClient: githubv4mock.NewMockedHTTPClient(
+ githubv4mock.NewQueryMatcher(
+ blameQueryShape{},
+ makeBlameVars("testowner", "testrepo", "main", "nonexistent.txt"),
+ githubv4mock.ErrorResponse("file not found"),
+ ),
+ ),
+ requestArgs: map[string]any{
+ "owner": "testowner",
+ "repo": "testrepo",
+ "path": "nonexistent.txt",
+ "ref": "main",
+ },
+ expectError: true,
+ expectedErrMsg: "file not found",
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ client := githubv4.NewClient(tc.mockedClient)
+ deps := BaseDeps{GQLClient: client}
+ handler := serverTool.Handler(deps)
+
+ request := createMCPRequest(tc.requestArgs)
+ result, err := handler(ContextWithDeps(context.Background(), deps), &request)
+
+ if tc.expectError {
+ require.NoError(t, err)
+ require.True(t, result.IsError)
+ errorContent := getErrorResult(t, result)
+ assert.Contains(t, errorContent.Text, tc.expectedErrMsg)
+ return
+ }
+
+ require.NoError(t, err)
+ require.False(t, result.IsError)
+ textContent := getTextResult(t, result)
+ if tc.validateResponse != nil {
+ tc.validateResponse(t, textContent.Text)
+ }
+ })
+ }
+
+ // Path validation must short-circuit before any network call. We supply
+ // a client with no matchers so any HTTP attempt would fail loudly.
+ t.Run("path validation rejects bad inputs", func(t *testing.T) {
+ client := githubv4.NewClient(githubv4mock.NewMockedHTTPClient())
+ deps := BaseDeps{GQLClient: client}
+ handler := serverTool.Handler(deps)
+
+ cases := []struct {
+ name string
+ path string
+ want string
+ }{
+ {"empty", " ", "must not be empty"},
+ {"absolute", "/etc/passwd", "must be relative"},
+ {"traversal", "src/../../../etc/passwd", "must not contain '..'"},
+ {"control char", "src/\x00bad.go", "control characters"},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ req := createMCPRequest(map[string]any{
+ "owner": "o", "repo": "r", "path": c.path,
+ })
+ result, err := handler(ContextWithDeps(context.Background(), deps), &req)
+ require.NoError(t, err)
+ require.True(t, result.IsError, "expected validation error for %q", c.path)
+ assert.Contains(t, getErrorResult(t, result).Text, c.want)
+ })
+ }
+ })
+
+ // Line-window and cursor pagination validation also short-circuits.
+ t.Run("line-range argument validation", func(t *testing.T) {
+ client := githubv4.NewClient(githubv4mock.NewMockedHTTPClient())
+ deps := BaseDeps{GQLClient: client}
+ handler := serverTool.Handler(deps)
+
+ cases := []struct {
+ name string
+ args map[string]any
+ want string
+ }{
+ {
+ "end before start",
+ map[string]any{"owner": "o", "repo": "r", "path": "f.go", "start_line": float64(10), "end_line": float64(5)},
+ "end_line must be >= start_line when both are provided",
+ },
+ {
+ "start line zero",
+ map[string]any{"owner": "o", "repo": "r", "path": "f.go", "start_line": float64(0)},
+ "start_line must be omitted or >= 1",
+ },
+ {
+ "end line zero",
+ map[string]any{"owner": "o", "repo": "r", "path": "f.go", "end_line": float64(0)},
+ "end_line must be omitted or >= 1",
+ },
+ {
+ "page not supported",
+ map[string]any{"owner": "o", "repo": "r", "path": "f.go", "page": float64(1)},
+ "cursor-based pagination",
+ },
+ {
+ "invalid after cursor",
+ map[string]any{"owner": "o", "repo": "r", "path": "f.go", "after": "not-a-cursor"},
+ "after cursor is invalid",
+ },
+ {
+ "perPage too large",
+ map[string]any{"owner": "o", "repo": "r", "path": "f.go", "perPage": float64(101)},
+ "perPage must be between 1 and 100 when provided",
+ },
+ {
+ "perPage zero",
+ map[string]any{"owner": "o", "repo": "r", "path": "f.go", "perPage": float64(0)},
+ "perPage must be between 1 and 100 when provided",
+ },
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ req := createMCPRequest(c.args)
+ result, err := handler(ContextWithDeps(context.Background(), deps), &req)
+ require.NoError(t, err)
+ require.True(t, result.IsError)
+ assert.Contains(t, getErrorResult(t, result).Text, c.want)
+ })
+ }
+ })
+
+ // Truncation: hand-build a response with > maxBlameRanges to verify
+ // the cap is applied and surfaced.
+ t.Run("truncation at maxBlameRanges", func(t *testing.T) {
+ ranges := make([]map[string]any, 0, maxBlameRanges+5)
+ for i := range maxBlameRanges + 5 {
+ ranges = append(ranges, map[string]any{
+ "startingLine": i + 1, "endingLine": i + 1, "age": 0,
+ "commit": map[string]any{
+ "oid": "sha-shared",
+ "message": "shared",
+ "committedDate": "2024-01-01T00:00:00Z",
+ "author": map[string]any{"name": "n", "email": "e@x", "user": nil},
+ },
+ })
+ }
+ mocked := githubv4mock.NewMockedHTTPClient(
+ githubv4mock.NewQueryMatcher(
+ blameQueryShape{},
+ makeBlameVars("o", "r", "HEAD", "huge.txt"),
+ githubv4mock.DataResponse(map[string]any{
+ "repository": map[string]any{
+ "defaultBranchRef": map[string]any{"name": "main"},
+ "object": map[string]any{
+ "__typename": "Commit",
+ "blame": map[string]any{"ranges": ranges},
+ },
+ },
+ }),
+ ),
+ )
+ // Use a large perPage so the truncated set is observable on a
+ // single page.
+ req := createMCPRequest(map[string]any{
+ "owner": "o", "repo": "r", "path": "huge.txt", "perPage": float64(100),
+ })
+ client := githubv4.NewClient(mocked)
+ deps := BaseDeps{GQLClient: client}
+ handler := serverTool.Handler(deps)
+ result, err := handler(ContextWithDeps(context.Background(), deps), &req)
+ require.NoError(t, err)
+ require.False(t, result.IsError)
+
+ var br BlameResult
+ require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &br))
+ assert.True(t, br.Truncated, "truncation flag must be set")
+ assert.Equal(t, maxBlameRanges+5, br.TotalRanges)
+ assert.Len(t, br.Ranges, 100, "perPage limits the page size")
+ assert.True(t, br.PageInfo.HasNextPage)
+ assert.NotEmpty(t, br.PageInfo.EndCursor)
+ })
+}
func Test_ListRepositoryCollaborators(t *testing.T) {
// Verify tool definition once
@@ -4405,7 +5407,6 @@ func Test_ListRepositoryCollaborators(t *testing.T) {
name string
args map[string]any
mockResponses []MockBackendOption
- wantErr bool
errContains string
}{
{
diff --git a/pkg/github/search.go b/pkg/github/search.go
index 9a8d182887..42ba2896f3 100644
--- a/pkg/github/search.go
+++ b/pkg/github/search.go
@@ -163,22 +163,22 @@ func SearchRepositories(t translations.TranslationHelperFunc) inventory.ServerTo
}
callResult := utils.NewToolResultText(string(r))
- if deps.IsFeatureEnabled(ctx, FeatureFlagIFCLabels) {
- attachSearchRepositoriesIFCLabel(result.Repositories, callResult)
- }
+ attachSearchRepositoriesIFCLabel(ctx, deps, result.Repositories, callResult)
return callResult, nil, nil
},
)
}
// attachSearchRepositoriesIFCLabel joins per-repository IFC labels across
-// every matched repository and attaches the result to callResult. Visibility
-// is read directly from the search response — no extra API call. The join
-// math is shared with search_issues via ifc.LabelSearchIssues: integrity is
-// always untrusted; confidentiality is private if any matched repository is
-// private, otherwise public.
-func attachSearchRepositoriesIFCLabel(repos []*github.Repository, callResult *mcp.CallToolResult) {
- if callResult == nil || callResult.IsError {
+// every matched repository and attaches the result to callResult when IFC
+// labels are enabled. Visibility is read directly from the search response —
+// no extra API call. The join math is shared with search_issues via
+// ifc.LabelSearchIssues: integrity is always untrusted; confidentiality is
+// private if any matched repository is private, otherwise public. The
+// feature-flag check is centralized here (mirroring the attach* helpers in
+// ifc_labels.go) so the handler can call this unconditionally.
+func attachSearchRepositoriesIFCLabel(ctx context.Context, deps ToolDependencies, repos []*github.Repository, callResult *mcp.CallToolResult) {
+ if callResult == nil || callResult.IsError || !deps.IsFeatureEnabled(ctx, FeatureFlagIFCLabels) {
return
}
@@ -187,10 +187,7 @@ func attachSearchRepositoriesIFCLabel(repos []*github.Repository, callResult *mc
visibilities = append(visibilities, repo.GetPrivate())
}
- if callResult.Meta == nil {
- callResult.Meta = mcp.Meta{}
- }
- callResult.Meta["ifc"] = ifc.LabelSearchIssues(visibilities)
+ setIFCLabel(callResult, ifc.LabelSearchIssues(visibilities))
}
// SearchCode creates a tool to search for code across GitHub repositories.
@@ -304,7 +301,18 @@ func SearchCode(t translations.TranslationHelperFunc) inventory.ServerTool {
return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil
}
- return utils.NewToolResultText(string(r)), nil, nil
+ callResult := utils.NewToolResultText(string(r))
+ // Code search spans repositories and exposes file contents
+ // (untrusted). Confidentiality is the IFC join across every matched
+ // repository's visibility, read directly from the search response.
+ visibilities := make([]bool, 0, len(result.CodeResults))
+ for _, code := range result.CodeResults {
+ if code.Repository != nil {
+ visibilities = append(visibilities, code.Repository.GetPrivate())
+ }
+ }
+ callResult = attachJoinedIFCLabel(ctx, deps, callResult, visibilities, ifc.LabelSearchIssues)
+ return callResult, nil, nil
},
)
}
@@ -392,7 +400,11 @@ func userOrOrgHandler(ctx context.Context, accountType string, deps ToolDependen
if err != nil {
return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil
}
- return utils.NewToolResultText(string(r)), nil, nil
+ callResult := utils.NewToolResultText(string(r))
+ // User and organization search returns public profile information that is
+ // authored by the account holders themselves, so it is public-untrusted.
+ callResult = attachStaticIFCLabel(ctx, deps, callResult, ifc.PublicUntrusted())
+ return callResult, nil, nil
}
// SearchUsers creates a tool to search for GitHub users.
@@ -580,7 +592,18 @@ func SearchCommits(t translations.TranslationHelperFunc) inventory.ServerTool {
return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil
}
- return utils.NewToolResultText(string(r)), nil, nil
+ callResult := utils.NewToolResultText(string(r))
+ // Commit search spans repositories and exposes commit content
+ // (untrusted). Confidentiality is the IFC join across every matched
+ // repository's visibility, read directly from the search response.
+ visibilities := make([]bool, 0, len(result.Commits))
+ for _, commit := range result.Commits {
+ if commit.Repository != nil {
+ visibilities = append(visibilities, commit.Repository.GetPrivate())
+ }
+ }
+ callResult = attachJoinedIFCLabel(ctx, deps, callResult, visibilities, ifc.LabelSearchIssues)
+ return callResult, nil, nil
},
)
}
diff --git a/pkg/github/secret_scanning.go b/pkg/github/secret_scanning.go
index e2605274f0..18cfe73771 100644
--- a/pkg/github/secret_scanning.go
+++ b/pkg/github/secret_scanning.go
@@ -8,6 +8,7 @@ import (
"net/http"
ghErrors "github.com/github/github-mcp-server/pkg/errors"
+ "github.com/github/github-mcp-server/pkg/ifc"
"github.com/github/github-mcp-server/pkg/inventory"
"github.com/github/github-mcp-server/pkg/scopes"
"github.com/github/github-mcp-server/pkg/translations"
@@ -89,7 +90,12 @@ func GetSecretScanningAlert(t translations.TranslationHelperFunc) inventory.Serv
return nil, nil, fmt.Errorf("failed to marshal alert: %w", err)
}
- return utils.NewToolResultText(string(r)), nil, nil
+ result := utils.NewToolResultText(string(r))
+ // Secret scanning alerts are access-restricted regardless of repo
+ // visibility and surface the matched secret material itself, so the
+ // label is always private-untrusted.
+ result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelSecurityAlert())
+ return result, nil, nil
},
)
}
@@ -199,7 +205,12 @@ func ListSecretScanningAlerts(t translations.TranslationHelperFunc) inventory.Se
return nil, nil, fmt.Errorf("failed to marshal alerts: %w", err)
}
- return utils.NewToolResultText(string(r)), nil, nil
+ result := utils.NewToolResultText(string(r))
+ // Secret scanning alerts are access-restricted regardless of repo
+ // visibility and surface the matched secret material itself, so the
+ // label is always private-untrusted.
+ result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelSecurityAlert())
+ return result, nil, nil
},
)
}
diff --git a/pkg/github/security_advisories.go b/pkg/github/security_advisories.go
index ec84e27b15..36e114c1dc 100644
--- a/pkg/github/security_advisories.go
+++ b/pkg/github/security_advisories.go
@@ -8,6 +8,7 @@ import (
"net/http"
ghErrors "github.com/github/github-mcp-server/pkg/errors"
+ "github.com/github/github-mcp-server/pkg/ifc"
"github.com/github/github-mcp-server/pkg/inventory"
"github.com/github/github-mcp-server/pkg/scopes"
"github.com/github/github-mcp-server/pkg/translations"
@@ -203,7 +204,12 @@ func ListGlobalSecurityAdvisories(t translations.TranslationHelperFunc) inventor
return nil, nil, fmt.Errorf("failed to marshal advisories: %w", err)
}
- return utils.NewToolResultText(string(r)), nil, nil
+ result := utils.NewToolResultText(string(r))
+ // Global advisories come from the world-readable GitHub Advisory
+ // Database (public) but contain externally authored prose
+ // (untrusted).
+ result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelGlobalSecurityAdvisory())
+ return result, nil, nil
},
)
}
@@ -307,7 +313,17 @@ func ListRepositorySecurityAdvisories(t translations.TranslationHelperFunc) inve
return nil, nil, fmt.Errorf("failed to marshal advisories: %w", err)
}
- return utils.NewToolResultText(string(r)), nil, nil
+ result := utils.NewToolResultText(string(r))
+ // Repository advisories carry externally authored prose (untrusted).
+ // Confidentiality follows repo visibility, but draft/triage/closed
+ // advisories are not world-readable even on a public repo, so the
+ // result is only public when every returned advisory is published.
+ allPublished := allAdvisoriesPublished(advisories)
+ result = attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, result,
+ func(isPrivate bool) ifc.SecurityLabel {
+ return ifc.LabelRepositorySecurityAdvisory(isPrivate, allPublished)
+ })
+ return result, nil, nil
},
)
}
@@ -364,7 +380,11 @@ func GetGlobalSecurityAdvisory(t translations.TranslationHelperFunc) inventory.S
return nil, nil, fmt.Errorf("failed to marshal advisory: %w", err)
}
- return utils.NewToolResultText(string(r)), nil, nil
+ result := utils.NewToolResultText(string(r))
+ // A global advisory is world-readable (public) but externally
+ // authored (untrusted).
+ result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelGlobalSecurityAdvisory())
+ return result, nil, nil
},
)
}
@@ -459,7 +479,28 @@ func ListOrgRepositorySecurityAdvisories(t translations.TranslationHelperFunc) i
return nil, nil, fmt.Errorf("failed to marshal advisories: %w", err)
}
- return utils.NewToolResultText(string(r)), nil, nil
+ result := utils.NewToolResultText(string(r))
+ // Org-wide advisory listings span the organization's repositories
+ // (including private ones) and are restricted to org members, so
+ // they are conservatively labeled private-untrusted (isPrivate=true,
+ // which forces private regardless of publication state).
+ result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelRepositorySecurityAdvisory(true, false))
+ return result, nil, nil
},
)
}
+
+// allAdvisoriesPublished reports whether every advisory in the slice is in the
+// "published" state. Repository security advisories can also be in draft,
+// triage, or closed states, none of which are world-readable even on a public
+// repository. An empty slice is treated as published (true) since there is no
+// non-public content to protect. Used to decide whether a repository advisory
+// listing may carry a public confidentiality label.
+func allAdvisoriesPublished(advisories []*github.SecurityAdvisory) bool {
+ for _, advisory := range advisories {
+ if advisory.GetState() != "published" {
+ return false
+ }
+ }
+ return true
+}
diff --git a/pkg/github/security_advisories_test.go b/pkg/github/security_advisories_test.go
index f45c2e4210..d02908610d 100644
--- a/pkg/github/security_advisories_test.go
+++ b/pkg/github/security_advisories_test.go
@@ -10,6 +10,7 @@ import (
"github.com/github/github-mcp-server/pkg/translations"
"github.com/google/go-github/v87/github"
"github.com/google/jsonschema-go/jsonschema"
+ "github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -370,6 +371,132 @@ func Test_ListRepositorySecurityAdvisories(t *testing.T) {
}
}
+// Test_ListRepositorySecurityAdvisories_IFC_FeatureFlag verifies the IFC label
+// attached to list_repository_security_advisories. The label is only present
+// when the ifc_labels feature flag is enabled, and — critically — confidentiality
+// is public only when the repository is public AND every returned advisory is
+// published. Draft/triage/closed advisories are not world-readable even on a
+// public repo, so a result containing one must be labeled private. This guards
+// against the under-classification raised in PR review.
+func Test_ListRepositorySecurityAdvisories_IFC_FeatureFlag(t *testing.T) {
+ t.Parallel()
+
+ toolDef := ListRepositorySecurityAdvisories(translations.NullTranslationHelper)
+
+ publishedAdvisory := &github.SecurityAdvisory{
+ GHSAID: github.Ptr("GHSA-1111-1111-1111"),
+ Summary: github.Ptr("Published advisory"),
+ State: github.Ptr("published"),
+ }
+ draftAdvisory := &github.SecurityAdvisory{
+ GHSAID: github.Ptr("GHSA-2222-2222-2222"),
+ Summary: github.Ptr("Draft advisory"),
+ State: github.Ptr("draft"),
+ }
+
+ makeMockClient := func(isPrivate bool, advisories []*github.SecurityAdvisory) *http.Client {
+ return MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
+ GetReposSecurityAdvisoriesByOwnerByRepo: mockResponse(t, http.StatusOK, advisories),
+ GetReposByOwnerByRepo: mockResponse(t, http.StatusOK, map[string]any{
+ "name": "repo",
+ "private": isPrivate,
+ }),
+ })
+ }
+
+ reqParams := map[string]any{
+ "owner": "owner",
+ "repo": "repo",
+ }
+
+ readIFC := func(t *testing.T, result *mcp.CallToolResult) (map[string]any, bool) {
+ t.Helper()
+ if result.Meta == nil {
+ return nil, false
+ }
+ label, ok := result.Meta["ifc"]
+ if !ok {
+ return nil, false
+ }
+ labelJSON, err := json.Marshal(label)
+ require.NoError(t, err)
+ var labelMap map[string]any
+ require.NoError(t, json.Unmarshal(labelJSON, &labelMap))
+ return labelMap, true
+ }
+
+ t.Run("feature flag disabled omits ifc label", func(t *testing.T) {
+ t.Parallel()
+ deps := BaseDeps{Client: mustNewGHClient(t, makeMockClient(false, []*github.SecurityAdvisory{publishedAdvisory}))}
+ handler := toolDef.Handler(deps)
+
+ request := createMCPRequest(reqParams)
+ result, err := handler(ContextWithDeps(context.Background(), deps), &request)
+ require.NoError(t, err)
+ require.False(t, result.IsError)
+ assert.Nil(t, result.Meta, "result meta should be nil when IFC labels are disabled")
+ })
+
+ t.Run("public repo with only published advisories is public", func(t *testing.T) {
+ t.Parallel()
+ deps := BaseDeps{
+ Client: mustNewGHClient(t, makeMockClient(false, []*github.SecurityAdvisory{publishedAdvisory})),
+ featureChecker: featureCheckerFor(FeatureFlagIFCLabels),
+ }
+ handler := toolDef.Handler(deps)
+
+ request := createMCPRequest(reqParams)
+ result, err := handler(ContextWithDeps(context.Background(), deps), &request)
+ require.NoError(t, err)
+ require.False(t, result.IsError)
+
+ label, ok := readIFC(t, result)
+ require.True(t, ok, "result meta should contain ifc key")
+ assert.Equal(t, "untrusted", label["integrity"])
+ assert.Equal(t, "public", label["confidentiality"])
+ })
+
+ t.Run("public repo with a draft advisory is private", func(t *testing.T) {
+ t.Parallel()
+ // Reviewer scenario: a draft advisory on a public repo is not
+ // world-readable, so the label must not be public.
+ deps := BaseDeps{
+ Client: mustNewGHClient(t, makeMockClient(false, []*github.SecurityAdvisory{publishedAdvisory, draftAdvisory})),
+ featureChecker: featureCheckerFor(FeatureFlagIFCLabels),
+ }
+ handler := toolDef.Handler(deps)
+
+ request := createMCPRequest(reqParams)
+ result, err := handler(ContextWithDeps(context.Background(), deps), &request)
+ require.NoError(t, err)
+ require.False(t, result.IsError)
+
+ label, ok := readIFC(t, result)
+ require.True(t, ok, "result meta should contain ifc key")
+ assert.Equal(t, "untrusted", label["integrity"])
+ assert.Equal(t, "private", label["confidentiality"], "draft advisory on public repo must be private")
+ })
+
+ t.Run("private repo is private", func(t *testing.T) {
+ t.Parallel()
+ deps := BaseDeps{
+ Client: mustNewGHClient(t, makeMockClient(true, []*github.SecurityAdvisory{publishedAdvisory})),
+ featureChecker: featureCheckerFor(FeatureFlagIFCLabels),
+ }
+ handler := toolDef.Handler(deps)
+
+ request := createMCPRequest(reqParams)
+ result, err := handler(ContextWithDeps(context.Background(), deps), &request)
+ require.NoError(t, err)
+ require.False(t, result.IsError)
+
+ label, ok := readIFC(t, result)
+ require.True(t, ok, "result meta should contain ifc key")
+ assert.Equal(t, "untrusted", label["integrity"])
+ assert.Equal(t, "private", label["confidentiality"])
+ })
+}
+
func Test_ListOrgRepositorySecurityAdvisories(t *testing.T) {
// Verify tool definition once
toolDef := ListOrgRepositorySecurityAdvisories(translations.NullTranslationHelper)
diff --git a/pkg/github/server.go b/pkg/github/server.go
index f56ac7d3a8..7ec5837c3a 100644
--- a/pkg/github/server.go
+++ b/pkg/github/server.go
@@ -111,7 +111,7 @@ func NewMCPServer(ctx context.Context, cfg *MCPServerConfig, deps ToolDependenci
// remote/HTTP server also serves them, fixing the "-32002 Resource not
// found" error clients hit after the tool returns a ui:// URI.
if UIAssetsAvailable() {
- RegisterUIResources(ghServer)
+ RegisterUIResources(ghServer, cfg.ReadOnly)
}
return ghServer, nil
diff --git a/pkg/github/tools.go b/pkg/github/tools.go
index d1d585b3fa..906fa777d7 100644
--- a/pkg/github/tools.go
+++ b/pkg/github/tools.go
@@ -180,6 +180,7 @@ func AllTools(t translations.TranslationHelperFunc) []inventory.ServerTool {
SearchCode(t),
SearchCommits(t),
GetCommit(t),
+ GetFileBlame(t),
ListBranches(t),
ListTags(t),
GetTag(t),
diff --git a/pkg/github/toolset_instructions.go b/pkg/github/toolset_instructions.go
index bc9da4e65c..ba6659612a 100644
--- a/pkg/github/toolset_instructions.go
+++ b/pkg/github/toolset_instructions.go
@@ -39,6 +39,10 @@ func generateProjectsToolsetInstructions(_ *inventory.Inventory) string {
Workflow: 1) list_project_fields (get field IDs), 2) list_project_items (with pagination), 3) optional updates.
+Project lifecycle: Use create_project to create a new ProjectsV2 for a user or organization (requires owner_type and title). Returns the new project's id, number, title, and url; pass the returned number as project_number to subsequent project tools.
+
+Iteration fields: Use create_iteration_field to add a new ITERATION field (e.g. "Sprint") to an existing project. Required: field_name, iteration_duration (days), start_date (YYYY-MM-DD). Only pass the iterations array when iterations need varying durations, breaks between them, or specific titles; otherwise omit it and GitHub creates three default iterations of iteration_duration days starting on start_date.
+
Status updates: Use list_project_status_updates to read recent project status updates (newest first). Use get_project_status_update with a node ID to get a single update. Use create_project_status_update to create a new status update for a project.
Field usage:
diff --git a/pkg/github/ui_resources.go b/pkg/github/ui_resources.go
index c41d2ac3f1..28051c0c4a 100644
--- a/pkg/github/ui_resources.go
+++ b/pkg/github/ui_resources.go
@@ -10,7 +10,10 @@ import (
// These are static resources (not templates) that serve HTML content for
// MCP App-enabled tools. The HTML is built from React/Primer components
// in the ui/ directory using `script/build-ui`.
-func RegisterUIResources(s *mcp.Server) {
+//
+// Resource metadata follows the stable 2026-01-26 MCP Apps spec:
+// https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/2026-01-26/apps.mdx
+func RegisterUIResources(s *mcp.Server, readOnly bool) {
// Register the get_me UI resource
s.AddResource(
&mcp.Resource{
@@ -27,14 +30,14 @@ func RegisterUIResources(s *mcp.Server) {
URI: GetMeUIResourceURI,
MIMEType: MCPAppMIMEType,
Text: html,
- // MCP Apps UI metadata - CSP configuration to allow loading GitHub avatars
- // See: https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/draft/apps.mdx
Meta: mcp.Meta{
"ui": map[string]any{
+ // Allow loading images from GitHub's avatar CDN.
"csp": map[string]any{
- // Allow loading images from GitHub's avatar CDN
"resourceDomains": []string{"https://avatars.githubusercontent.com"},
},
+ // Profile card renders inline within chat without a host border.
+ "prefersBorder": false,
},
},
},
@@ -43,6 +46,10 @@ func RegisterUIResources(s *mcp.Server) {
},
)
+ if readOnly {
+ return
+ }
+
// Register the issue_write UI resource
s.AddResource(
&mcp.Resource{
@@ -59,6 +66,14 @@ func RegisterUIResources(s *mcp.Server) {
URI: IssueWriteUIResourceURI,
MIMEType: MCPAppMIMEType,
Text: html,
+ Meta: mcp.Meta{
+ "ui": map[string]any{
+ // No external origins required; documents the secure default.
+ "csp": map[string]any{},
+ // Form surface benefits from a host-provided border.
+ "prefersBorder": true,
+ },
+ },
},
},
}, nil
@@ -81,6 +96,12 @@ func RegisterUIResources(s *mcp.Server) {
URI: PullRequestWriteUIResourceURI,
MIMEType: MCPAppMIMEType,
Text: html,
+ Meta: mcp.Meta{
+ "ui": map[string]any{
+ "csp": map[string]any{},
+ "prefersBorder": true,
+ },
+ },
},
},
}, nil
diff --git a/pkg/github/ui_resources_test.go b/pkg/github/ui_resources_test.go
index 928950ac73..7e67d5faed 100644
--- a/pkg/github/ui_resources_test.go
+++ b/pkg/github/ui_resources_test.go
@@ -2,6 +2,7 @@ package github
import (
"context"
+ "slices"
"testing"
"github.com/github/github-mcp-server/pkg/inventory"
@@ -26,7 +27,7 @@ func TestRegisterUIResources_ReadableViaClient(t *testing.T) {
}
srv := mcp.NewServer(&mcp.Implementation{Name: "test", Version: "0.0.1"}, nil)
- RegisterUIResources(srv)
+ RegisterUIResources(srv, false)
// Connect an in-memory client/server pair and read each advertised URI.
st, ct := mcp.NewInMemoryTransports()
@@ -113,6 +114,49 @@ func TestNewMCPServer_RegistersUIResources(t *testing.T) {
assert.Equal(t, MCPAppMIMEType, res.Contents[0].MIMEType)
}
+func TestRegisterUIResources_ReadOnlySkipsWriteResources(t *testing.T) {
+ t.Parallel()
+
+ srv := mcp.NewServer(&mcp.Implementation{Name: "test", Version: "0.0.1"}, nil)
+ RegisterUIResources(srv, true)
+
+ st, ct := mcp.NewInMemoryTransports()
+
+ type clientResult struct {
+ res *mcp.ListResourcesResult
+ err error
+ }
+ clientCh := make(chan clientResult, 1)
+ go func() {
+ client := mcp.NewClient(&mcp.Implementation{Name: "test-client"}, nil)
+ cs, err := client.Connect(context.Background(), ct, nil)
+ if err != nil {
+ clientCh <- clientResult{err: err}
+ return
+ }
+ defer func() { _ = cs.Close() }()
+
+ res, err := cs.ListResources(context.Background(), nil)
+ clientCh <- clientResult{res: res, err: err}
+ }()
+
+ ss, err := srv.Connect(context.Background(), st, nil)
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = ss.Close() })
+
+ got := <-clientCh
+ require.NoError(t, got.err)
+ require.NotNil(t, got.res)
+
+ names := make([]string, 0, len(got.res.Resources))
+ for _, res := range got.res.Resources {
+ names = append(names, res.Name)
+ }
+ slices.Sort(names)
+
+ assert.Equal(t, []string{"get_me_ui"}, names)
+}
+
// mustEmptyInventory builds an empty inventory for tests that only care about
// resources/prompts registered outside the inventory (such as the UI resources).
func mustEmptyInventory(t *testing.T) *inventory.Inventory {
diff --git a/pkg/http/handler_test.go b/pkg/http/handler_test.go
index a36469133c..4f697ee0cb 100644
--- a/pkg/http/handler_test.go
+++ b/pkg/http/handler_test.go
@@ -904,3 +904,44 @@ func TestInsidersRoutePreservesUIMeta(t *testing.T) {
require.False(t, plainEnabled, "FF should be off for non-insiders ctx")
require.Len(t, plainTools, 1)
}
+
+// TestUIMetaStrippedWhenClientLacksCapability verifies that even on the
+// /insiders path (where the feature flag is on), UI metadata is stripped from
+// tools/list responses when the client did NOT advertise the
+// io.modelcontextprotocol/ui extension capability. Per the 2026-01-26 MCP
+// Apps spec, servers SHOULD check client capabilities before exposing
+// UI-enabled tools.
+func TestUIMetaStrippedWhenClientLacksCapability(t *testing.T) {
+ const uiURI = "ui://test/widget"
+ uiTool := mockTool("with_ui", "repos", true)
+ uiTool.Tool.Meta = mcp.Meta{"ui": map[string]any{"resourceUri": uiURI}}
+
+ checker := createHTTPFeatureChecker(nil, false)
+ build := func() *inventory.Inventory {
+ inv, err := inventory.NewBuilder().
+ SetTools([]inventory.ServerTool{uiTool}).
+ WithFeatureChecker(checker).
+ WithToolsets([]string{"all"}).
+ Build()
+ require.NoError(t, err)
+ return inv
+ }
+
+ insidersCtx := ghcontext.WithInsidersMode(context.Background(), true)
+ withoutUICap := ghcontext.WithUISupport(insidersCtx, false)
+ withUICap := ghcontext.WithUISupport(insidersCtx, true)
+
+ stripped := build().ToolsForRegistration(withoutUICap)
+ require.Len(t, stripped, 1)
+ require.Nil(t, stripped[0].Tool.Meta["ui"], "_meta.ui should be stripped when client lacks UI capability")
+
+ preserved := build().ToolsForRegistration(withUICap)
+ require.Len(t, preserved, 1)
+ require.NotNil(t, preserved[0].Tool.Meta["ui"], "_meta.ui should be preserved when client advertises UI capability")
+ require.Equal(t, uiURI, preserved[0].Tool.Meta["ui"].(map[string]any)["resourceUri"])
+
+ // Unknown capability falls through to the FF gate (insiders ctx → kept).
+ unknown := build().ToolsForRegistration(insidersCtx)
+ require.Len(t, unknown, 1)
+ require.NotNil(t, unknown[0].Tool.Meta["ui"], "_meta.ui should be preserved when capability is unknown and FF is on")
+}
diff --git a/pkg/http/server_test.go b/pkg/http/server_test.go
index 62511775a9..1804134651 100644
--- a/pkg/http/server_test.go
+++ b/pkg/http/server_test.go
@@ -94,10 +94,10 @@ func TestCreateHTTPFeatureChecker(t *testing.T) {
wantEnabled: true,
},
{
- name: "insiders mode enables internal-only insiders flags",
+ name: "insiders mode does not auto-enable ifc labels",
flagName: github.FeatureFlagIFCLabels,
insidersMode: true,
- wantEnabled: true,
+ wantEnabled: false,
},
{
name: "insiders mode does not enable granular flags",
diff --git a/pkg/ifc/ifc.go b/pkg/ifc/ifc.go
index e6eeb407bc..fefe542e3d 100644
--- a/pkg/ifc/ifc.go
+++ b/pkg/ifc/ifc.go
@@ -59,8 +59,18 @@ func PrivateUntrusted() SecurityLabel {
}
}
+// LabelGetMe returns the IFC label for the authenticated user's own profile
+// (get_me).
+//
+// Integrity is trusted: this is GitHub-maintained data about the caller's own
+// account, not attacker-authored content.
+//
+// Confidentiality is private. The result includes fields that are NOT part of
+// the user's public profile — private_gists, total_private_repos, and
+// owned_private_repos — which are visible only to the authenticated user. The
+// result therefore must not be treated as world-readable.
func LabelGetMe() SecurityLabel {
- return PublicTrusted()
+ return PrivateTrusted()
}
// LabelListIssues returns the IFC label for a list_issues result.
@@ -98,6 +108,16 @@ func LabelGetFileContents(isPrivate bool) SecurityLabel {
//
// An empty result set is treated as public-untrusted (no repository data is
// leaked).
+//
+// Why a single joined label rather than one label per item: a tool result is
+// delivered as one opaque payload (a single content block) and the IFC engine
+// makes one allow/deny decision per flow at egress. Once the items share a
+// buffer in the agent's context they can be copied anywhere together, so the
+// only sound bound for the whole result is the meet of every item's label.
+// Per-item labels would only become load-bearing if the enforcement engine
+// could partition a result and route individual items to different sinks;
+// until then they would invite unsafe declassification of a "public" item that
+// actually arrived alongside private data.
func LabelSearchIssues(repoVisibilities []bool) SecurityLabel {
for _, isPrivate := range repoVisibilities {
if isPrivate {
@@ -106,3 +126,206 @@ func LabelSearchIssues(repoVisibilities []bool) SecurityLabel {
}
return PublicUntrusted()
}
+
+// LabelRepoMetadata returns the IFC label for structural repository metadata
+// that only collaborators with write access can define: labels, branches,
+// tags, releases, issue types, issue field definitions, discussion
+// categories, and the collaborator roster.
+//
+// Integrity is trusted because, unlike issue/PR/comment bodies, these
+// artifacts cannot be authored by arbitrary outsiders — creating a branch,
+// tag, release, or label requires push access, so the data reflects decisions
+// made by the repository's trusted writers rather than attacker-controllable
+// input.
+//
+// Confidentiality follows repository visibility: public repositories are
+// universally readable; private repositories restrict the reader set (the
+// opaque "private" marker, resolved client-side at egress time).
+func LabelRepoMetadata(isPrivate bool) SecurityLabel {
+ if isPrivate {
+ return PrivateTrusted()
+ }
+ return PublicTrusted()
+}
+
+// LabelRelease returns the IFC label for repository releases (list_releases,
+// get_latest_release, get_release_by_tag).
+//
+// Integrity is trusted: releases are published by collaborators with push
+// access, not by arbitrary outsiders.
+//
+// Confidentiality is public only when the repository is public AND no returned
+// release is a draft. Draft releases are visible only to users with push
+// access — they are NOT world-readable even on a public repository — so a
+// result containing one must be private. hasDraft reflects whether any release
+// in the result is a draft; private repositories are always private regardless.
+func LabelRelease(isPrivate bool, hasDraft bool) SecurityLabel {
+ if isPrivate || hasDraft {
+ return PrivateTrusted()
+ }
+ return PublicTrusted()
+}
+
+// LabelCollaboratorRoster returns the IFC label for a repository's collaborator
+// list (list_repository_collaborators).
+//
+// Integrity is trusted: the roster is GitHub-maintained membership data, not
+// attacker-authored content.
+//
+// Confidentiality is always private. Listing collaborators requires push
+// access to the repository, so the roster is never world-readable — not even
+// for public repositories. This mirrors LabelTeam: membership data is
+// restricted regardless of the repository's own visibility.
+func LabelCollaboratorRoster() SecurityLabel {
+ return PrivateTrusted()
+}
+
+// LabelCommitContents returns the IFC label for committed repository content
+// reachable from the default branch and its history: commits, commit diffs,
+// and the repository file tree.
+//
+// It shares the reasoning of LabelGetFileContents. In public repositories any
+// outsider can land content via a pull request, so the integrity of committed
+// content is untrusted. In private repositories only collaborators can push,
+// so committed content is trusted. Confidentiality follows repository
+// visibility.
+func LabelCommitContents(isPrivate bool) SecurityLabel {
+ if isPrivate {
+ return PrivateTrusted()
+ }
+ return PublicUntrusted()
+}
+
+// LabelActionsResult returns the IFC label for GitHub Actions resources:
+// workflow definitions, runs, jobs, artifacts, and job logs.
+//
+// Integrity is untrusted. Workflow logs echo arbitrary text produced during a
+// run — including output derived from pull-request branches, dependency
+// downloads, and other attacker-influenceable sources — so log and artifact
+// content must be treated as low integrity. Workflow definitions are
+// themselves editable through pull requests in public repositories.
+//
+// Confidentiality follows repository visibility.
+func LabelActionsResult(isPrivate bool) SecurityLabel {
+ if isPrivate {
+ return PrivateUntrusted()
+ }
+ return PublicUntrusted()
+}
+
+// LabelSecurityAlert returns the IFC label for security findings: code
+// scanning alerts, secret scanning alerts, and Dependabot alerts.
+//
+// Integrity is untrusted because alert payloads embed attacker-influenceable
+// material — the offending code snippet, the matched secret string, or a
+// vulnerable dependency's advisory text — none of which the agent should treat
+// as a trustworthy instruction source.
+//
+// Confidentiality is always private. Security alerts are access-restricted by
+// GitHub regardless of repository visibility (only users with a security role
+// can read them), so the reader set is narrow even for public repositories.
+// Secret scanning results additionally surface the secret material itself.
+func LabelSecurityAlert() SecurityLabel {
+ return PrivateUntrusted()
+}
+
+// LabelGlobalSecurityAdvisory returns the IFC label for advisories served from
+// the public GitHub Advisory Database (global advisories).
+//
+// The advisory database is world-readable, so confidentiality is public.
+// Integrity is untrusted: advisory descriptions are externally authored prose
+// and must not be treated as a trusted instruction source.
+func LabelGlobalSecurityAdvisory() SecurityLabel {
+ return PublicUntrusted()
+}
+
+// LabelRepositorySecurityAdvisory returns the IFC label for repository- or
+// organization-scoped security advisories.
+//
+// Integrity is untrusted (externally authored advisory prose).
+//
+// Confidentiality is public only when the repository is public AND every
+// advisory in the result is in the "published" state. Repository security
+// advisories also exist in draft, triage, and closed states; those are visible
+// only to maintainers and are NOT world-readable even on a public repository.
+// Treating any non-published advisory as private (allPublished == false)
+// prevents misclassifying an unpublished advisory from a public repo as
+// public-readable. Private repositories are always private regardless of state.
+func LabelRepositorySecurityAdvisory(isPrivate bool, allPublished bool) SecurityLabel {
+ if isPrivate || !allPublished {
+ return PrivateUntrusted()
+ }
+ return PublicUntrusted()
+}
+
+// LabelGist returns the IFC label for gist content.
+//
+// Integrity is untrusted: gist contents are arbitrary user-authored text.
+// Confidentiality derives from the gist's own visibility rather than any
+// repository — public gists are universally readable, while secret gists are
+// restricted to those who hold the gist URL (modeled with the opaque "private"
+// marker).
+func LabelGist(isPublic bool) SecurityLabel {
+ if isPublic {
+ return PublicUntrusted()
+ }
+ return PrivateUntrusted()
+}
+
+// LabelGistList returns the IFC label for a list of gists belonging to a user,
+// joining the per-gist confidentiality across the result set.
+//
+// Integrity is untrusted (user-authored content). Confidentiality follows the
+// IFC meet: if any gist in the result is secret the joined label is private;
+// otherwise public. An empty result is treated as public-untrusted.
+//
+// See LabelSearchIssues for why list results carry a single joined label
+// rather than one label per item.
+func LabelGistList(gistVisibilities []bool) SecurityLabel {
+ for _, isPublic := range gistVisibilities {
+ if !isPublic {
+ return PrivateUntrusted()
+ }
+ }
+ return PublicUntrusted()
+}
+
+// LabelProject returns the IFC label for a GitHub Project (Projects v2) and its
+// items, status updates, and field definitions.
+//
+// Integrity is untrusted: project titles, item content, and status update
+// bodies are user-authored free text. Confidentiality derives from the
+// project's own public flag — public projects are universally readable, while
+// private projects restrict the reader set.
+func LabelProject(isPublic bool) SecurityLabel {
+ if isPublic {
+ return PublicUntrusted()
+ }
+ return PrivateUntrusted()
+}
+
+// LabelTeam returns the IFC label for organization team membership data
+// (get_teams, get_team_members).
+//
+// Integrity is trusted: team membership is maintained by GitHub and cannot be
+// forged by outside contributors, so it is not an attacker-controllable
+// instruction source.
+//
+// Confidentiality is private. Organization team rosters and the teams a user
+// belongs to are visible only to members of the organization, not to the
+// public, so the reader set is restricted (the opaque "private" marker).
+func LabelTeam() SecurityLabel {
+ return PrivateTrusted()
+}
+
+// LabelNotificationDetails returns the IFC label for the subject of a single
+// notification.
+//
+// Integrity is untrusted: a notification subject points at an issue, pull
+// request, comment, or discussion whose content is user-authored and may carry
+// attacker-controlled text. Confidentiality is private because notifications
+// are delivered to a specific recipient and may reference private
+// repositories; the result cannot be assumed to be publicly readable.
+func LabelNotificationDetails() SecurityLabel {
+ return PrivateUntrusted()
+}
diff --git a/pkg/ifc/ifc_test.go b/pkg/ifc/ifc_test.go
index 669f5ff0cc..90788a8cb7 100644
--- a/pkg/ifc/ifc_test.go
+++ b/pkg/ifc/ifc_test.go
@@ -49,3 +49,245 @@ func TestLabelSearchIssues(t *testing.T) {
})
}
}
+
+func TestLabelRepoMetadata(t *testing.T) {
+ t.Parallel()
+
+ t.Run("public repo metadata is trusted and public", func(t *testing.T) {
+ t.Parallel()
+ label := LabelRepoMetadata(false)
+ assert.Equal(t, IntegrityTrusted, label.Integrity)
+ assert.Equal(t, ConfidentialityPublic, label.Confidentiality)
+ })
+
+ t.Run("private repo metadata is trusted and private", func(t *testing.T) {
+ t.Parallel()
+ label := LabelRepoMetadata(true)
+ assert.Equal(t, IntegrityTrusted, label.Integrity)
+ assert.Equal(t, ConfidentialityPrivate, label.Confidentiality)
+ })
+}
+
+func TestLabelGetMe(t *testing.T) {
+ t.Parallel()
+
+ // get_me exposes private_gists/total_private_repos/owned_private_repos,
+ // which are not part of the public profile, so the result is trusted but
+ // private — never public.
+ label := LabelGetMe()
+ assert.Equal(t, IntegrityTrusted, label.Integrity)
+ assert.Equal(t, ConfidentialityPrivate, label.Confidentiality)
+}
+
+func TestLabelRelease(t *testing.T) {
+ t.Parallel()
+
+ t.Run("public repo with no draft is trusted and public", func(t *testing.T) {
+ t.Parallel()
+ label := LabelRelease(false, false)
+ assert.Equal(t, IntegrityTrusted, label.Integrity)
+ assert.Equal(t, ConfidentialityPublic, label.Confidentiality)
+ })
+
+ t.Run("public repo with a draft release is private", func(t *testing.T) {
+ t.Parallel()
+ // Draft releases are visible only to push-access users, so a draft on
+ // a public repo must not be labeled public.
+ label := LabelRelease(false, true)
+ assert.Equal(t, IntegrityTrusted, label.Integrity)
+ assert.Equal(t, ConfidentialityPrivate, label.Confidentiality)
+ })
+
+ t.Run("private repo is private regardless of draft", func(t *testing.T) {
+ t.Parallel()
+ for _, hasDraft := range []bool{false, true} {
+ label := LabelRelease(true, hasDraft)
+ assert.Equal(t, IntegrityTrusted, label.Integrity)
+ assert.Equal(t, ConfidentialityPrivate, label.Confidentiality)
+ }
+ })
+}
+
+func TestLabelCollaboratorRoster(t *testing.T) {
+ t.Parallel()
+
+ // A collaborator roster requires push access to list, so it is never
+ // world-readable — always trusted and private.
+ label := LabelCollaboratorRoster()
+ assert.Equal(t, IntegrityTrusted, label.Integrity)
+ assert.Equal(t, ConfidentialityPrivate, label.Confidentiality)
+}
+
+func TestLabelCommitContents(t *testing.T) {
+ t.Parallel()
+
+ t.Run("public repo commit content is untrusted and public", func(t *testing.T) {
+ t.Parallel()
+ label := LabelCommitContents(false)
+ assert.Equal(t, IntegrityUntrusted, label.Integrity)
+ assert.Equal(t, ConfidentialityPublic, label.Confidentiality)
+ })
+
+ t.Run("private repo commit content is trusted and private", func(t *testing.T) {
+ t.Parallel()
+ label := LabelCommitContents(true)
+ assert.Equal(t, IntegrityTrusted, label.Integrity)
+ assert.Equal(t, ConfidentialityPrivate, label.Confidentiality)
+ })
+}
+
+func TestLabelActionsResult(t *testing.T) {
+ t.Parallel()
+
+ t.Run("public repo actions result is untrusted and public", func(t *testing.T) {
+ t.Parallel()
+ label := LabelActionsResult(false)
+ assert.Equal(t, IntegrityUntrusted, label.Integrity)
+ assert.Equal(t, ConfidentialityPublic, label.Confidentiality)
+ })
+
+ t.Run("private repo actions result is untrusted and private", func(t *testing.T) {
+ t.Parallel()
+ label := LabelActionsResult(true)
+ assert.Equal(t, IntegrityUntrusted, label.Integrity)
+ assert.Equal(t, ConfidentialityPrivate, label.Confidentiality)
+ })
+}
+
+func TestLabelSecurityAlert(t *testing.T) {
+ t.Parallel()
+ label := LabelSecurityAlert()
+ assert.Equal(t, IntegrityUntrusted, label.Integrity)
+ assert.Equal(t, ConfidentialityPrivate, label.Confidentiality,
+ "security alerts are access-restricted regardless of repo visibility")
+}
+
+func TestLabelGlobalSecurityAdvisory(t *testing.T) {
+ t.Parallel()
+ label := LabelGlobalSecurityAdvisory()
+ assert.Equal(t, IntegrityUntrusted, label.Integrity)
+ assert.Equal(t, ConfidentialityPublic, label.Confidentiality)
+}
+
+func TestLabelRepositorySecurityAdvisory(t *testing.T) {
+ t.Parallel()
+
+ t.Run("public repo with all published advisories is untrusted and public", func(t *testing.T) {
+ t.Parallel()
+ label := LabelRepositorySecurityAdvisory(false, true)
+ assert.Equal(t, IntegrityUntrusted, label.Integrity)
+ assert.Equal(t, ConfidentialityPublic, label.Confidentiality)
+ })
+
+ t.Run("public repo with an unpublished advisory is untrusted and private", func(t *testing.T) {
+ t.Parallel()
+ // draft/triage/closed advisories are not world-readable even on a
+ // public repo, so confidentiality must be private.
+ label := LabelRepositorySecurityAdvisory(false, false)
+ assert.Equal(t, IntegrityUntrusted, label.Integrity)
+ assert.Equal(t, ConfidentialityPrivate, label.Confidentiality)
+ })
+
+ t.Run("private repo advisory is untrusted and private", func(t *testing.T) {
+ t.Parallel()
+ label := LabelRepositorySecurityAdvisory(true, true)
+ assert.Equal(t, IntegrityUntrusted, label.Integrity)
+ assert.Equal(t, ConfidentialityPrivate, label.Confidentiality)
+ })
+
+ t.Run("private repo with unpublished advisory is untrusted and private", func(t *testing.T) {
+ t.Parallel()
+ label := LabelRepositorySecurityAdvisory(true, false)
+ assert.Equal(t, IntegrityUntrusted, label.Integrity)
+ assert.Equal(t, ConfidentialityPrivate, label.Confidentiality)
+ })
+}
+
+func TestLabelGist(t *testing.T) {
+ t.Parallel()
+
+ t.Run("public gist is untrusted and public", func(t *testing.T) {
+ t.Parallel()
+ label := LabelGist(true)
+ assert.Equal(t, IntegrityUntrusted, label.Integrity)
+ assert.Equal(t, ConfidentialityPublic, label.Confidentiality)
+ })
+
+ t.Run("secret gist is untrusted and private", func(t *testing.T) {
+ t.Parallel()
+ label := LabelGist(false)
+ assert.Equal(t, IntegrityUntrusted, label.Integrity)
+ assert.Equal(t, ConfidentialityPrivate, label.Confidentiality)
+ })
+}
+
+func TestLabelGistList(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ visibilities []bool // true == public
+ wantConfidential Confidentiality
+ }{
+ {
+ name: "empty result is treated as public",
+ wantConfidential: ConfidentialityPublic,
+ },
+ {
+ name: "all public gists stay public",
+ visibilities: []bool{true, true},
+ wantConfidential: ConfidentialityPublic,
+ },
+ {
+ name: "any secret gist flips to private",
+ visibilities: []bool{true, false, true},
+ wantConfidential: ConfidentialityPrivate,
+ },
+ {
+ name: "all secret gists stay private",
+ visibilities: []bool{false, false},
+ wantConfidential: ConfidentialityPrivate,
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+ label := LabelGistList(tc.visibilities)
+ assert.Equal(t, IntegrityUntrusted, label.Integrity)
+ assert.Equal(t, tc.wantConfidential, label.Confidentiality)
+ })
+ }
+}
+
+func TestLabelProject(t *testing.T) {
+ t.Parallel()
+
+ t.Run("public project is untrusted and public", func(t *testing.T) {
+ t.Parallel()
+ label := LabelProject(true)
+ assert.Equal(t, IntegrityUntrusted, label.Integrity)
+ assert.Equal(t, ConfidentialityPublic, label.Confidentiality)
+ })
+
+ t.Run("private project is untrusted and private", func(t *testing.T) {
+ t.Parallel()
+ label := LabelProject(false)
+ assert.Equal(t, IntegrityUntrusted, label.Integrity)
+ assert.Equal(t, ConfidentialityPrivate, label.Confidentiality)
+ })
+}
+
+func TestLabelTeam(t *testing.T) {
+ t.Parallel()
+ label := LabelTeam()
+ assert.Equal(t, IntegrityTrusted, label.Integrity)
+ assert.Equal(t, ConfidentialityPrivate, label.Confidentiality)
+}
+
+func TestLabelNotificationDetails(t *testing.T) {
+ t.Parallel()
+ label := LabelNotificationDetails()
+ assert.Equal(t, IntegrityUntrusted, label.Integrity)
+ assert.Equal(t, ConfidentialityPrivate, label.Confidentiality)
+}
diff --git a/pkg/inventory/registry.go b/pkg/inventory/registry.go
index d147cbfc66..b8a70a3420 100644
--- a/pkg/inventory/registry.go
+++ b/pkg/inventory/registry.go
@@ -7,6 +7,7 @@ import (
"slices"
"sort"
+ ghcontext "github.com/github/github-mcp-server/pkg/context"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
@@ -169,26 +170,50 @@ func (r *Inventory) ToolsetDescriptions() map[ToolsetID]string {
// ToolsForRegistration returns AvailableTools(ctx) post-processed exactly as
// RegisterTools would expose them: with MCP Apps UI metadata stripped when
-// the remote_mcp_ui_apps feature flag is not enabled in ctx. Useful for
-// documentation generators and diagnostics that need the same view of the
-// tool surface the server would register.
+// the client cannot consume it. Useful for documentation generators and
+// diagnostics that need the same view of the tool surface the server would
+// register.
+//
+// The strip applies when EITHER of the following is true:
+//
+// - The remote_mcp_ui_apps feature flag is not enabled in ctx (server-side gate).
+// - The client explicitly did not advertise the io.modelcontextprotocol/ui
+// extension capability (per the 2026-01-26 MCP Apps spec, servers SHOULD
+// check client capabilities before exposing UI-enabled tools). When the
+// capability is unknown (e.g. stdio paths that do not populate the
+// context flag) the feature-flag gate is the sole source of truth.
func (r *Inventory) ToolsForRegistration(ctx context.Context) []ServerTool {
tools := r.AvailableTools(ctx)
- if !r.checkFeatureFlag(ctx, mcpAppsFeatureFlag) {
+ if shouldStripMCPAppsMetadata(ctx, r.checkFeatureFlag(ctx, mcpAppsFeatureFlag)) {
tools = stripMCPAppsMetadata(tools)
}
return tools
}
+// shouldStripMCPAppsMetadata centralises the strip decision so the same logic
+// is exercised by tests and by RegisterTools.
+func shouldStripMCPAppsMetadata(ctx context.Context, featureFlagEnabled bool) bool {
+ if !featureFlagEnabled {
+ return true
+ }
+ // Feature flag is on. Respect the client capability if it is known.
+ if supported, ok := ghcontext.HasUISupport(ctx); ok && !supported {
+ return true
+ }
+ return false
+}
+
// RegisterTools registers all available tools with the server using the provided dependencies.
-// The context is used for feature flag evaluation.
+// The context is used for feature flag evaluation and client capability checks.
//
// MCP Apps UI metadata (`_meta.ui`) is stripped from the registered tools
-// when the MCP Apps feature flag is not enabled for this request. The strip
-// happens here (rather than at Build() time) so the per-request context is
-// in scope — HTTP feature checkers that read insiders mode or user identity
-// from ctx would otherwise see context.Background() and falsely report the
-// flag off, even when the actual request arrived on the /insiders route.
+// when either the MCP Apps feature flag is not enabled for this request, or
+// the client did not advertise the io.modelcontextprotocol/ui extension. The
+// strip happens here (rather than at Build() time) so the per-request
+// context is in scope — HTTP feature checkers that read insiders mode or
+// user identity from ctx would otherwise see context.Background() and
+// falsely report the flag off, even when the actual request arrived on the
+// /insiders route.
func (r *Inventory) RegisterTools(ctx context.Context, s *mcp.Server, deps any) {
for _, tool := range r.ToolsForRegistration(ctx) {
tool.RegisterFunc(s, deps)
diff --git a/pkg/inventory/registry_test.go b/pkg/inventory/registry_test.go
index 372f756023..20b1fb718c 100644
--- a/pkg/inventory/registry_test.go
+++ b/pkg/inventory/registry_test.go
@@ -6,6 +6,7 @@ import (
"fmt"
"testing"
+ ghcontext "github.com/github/github-mcp-server/pkg/context"
"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/stretchr/testify/require"
)
@@ -2211,7 +2212,7 @@ func captureRegisteredTools(ctx context.Context, t *testing.T, reg *Inventory) [
toolCopy := tools[i].Tool
out = append(out, &toolCopy)
}
- if !reg.checkFeatureFlag(ctx, mcpAppsFeatureFlag) {
+ if shouldStripMCPAppsMetadata(ctx, reg.checkFeatureFlag(ctx, mcpAppsFeatureFlag)) {
for _, tt := range out {
delete(tt.Meta, "ui")
if len(tt.Meta) == 0 {
@@ -2221,3 +2222,55 @@ func captureRegisteredTools(ctx context.Context, t *testing.T, reg *Inventory) [
}
return out
}
+
+// TestShouldStripMCPAppsMetadata verifies the spec-conformant strip decision:
+// strip when the feature flag is off, OR when the client explicitly does not
+// advertise the io.modelcontextprotocol/ui extension.
+func TestShouldStripMCPAppsMetadata(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ setupCtx func() context.Context
+ ffOn bool
+ want bool
+ }{
+ {
+ name: "FF off, capability unknown -> strip",
+ setupCtx: context.Background,
+ ffOn: false,
+ want: true,
+ },
+ {
+ name: "FF off, capability present -> strip (FF wins)",
+ setupCtx: func() context.Context { return ghcontext.WithUISupport(context.Background(), true) },
+ ffOn: false,
+ want: true,
+ },
+ {
+ name: "FF on, capability unknown -> keep",
+ setupCtx: context.Background,
+ ffOn: true,
+ want: false,
+ },
+ {
+ name: "FF on, capability present -> keep",
+ setupCtx: func() context.Context { return ghcontext.WithUISupport(context.Background(), true) },
+ ffOn: true,
+ want: false,
+ },
+ {
+ name: "FF on, capability explicitly absent -> strip",
+ setupCtx: func() context.Context { return ghcontext.WithUISupport(context.Background(), false) },
+ ffOn: true,
+ want: true,
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ got := shouldStripMCPAppsMetadata(tc.setupCtx(), tc.ffOn)
+ require.Equal(t, tc.want, got)
+ })
+ }
+}
diff --git a/ui/package-lock.json b/ui/package-lock.json
index 13d78a25a8..4046bc28f9 100644
--- a/ui/package-lock.json
+++ b/ui/package-lock.json
@@ -1694,381 +1694,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/@rollup/rollup-android-arm-eabi": {
- "version": "4.60.4",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz",
- "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==",
- "cpu": [
- "arm"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ],
- "peer": true
- },
- "node_modules/@rollup/rollup-android-arm64": {
- "version": "4.60.4",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz",
- "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ],
- "peer": true
- },
- "node_modules/@rollup/rollup-darwin-arm64": {
- "version": "4.60.4",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz",
- "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "peer": true
- },
- "node_modules/@rollup/rollup-darwin-x64": {
- "version": "4.60.4",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz",
- "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "peer": true
- },
- "node_modules/@rollup/rollup-freebsd-arm64": {
- "version": "4.60.4",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz",
- "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "freebsd"
- ],
- "peer": true
- },
- "node_modules/@rollup/rollup-freebsd-x64": {
- "version": "4.60.4",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz",
- "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "freebsd"
- ],
- "peer": true
- },
- "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
- "version": "4.60.4",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz",
- "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==",
- "cpu": [
- "arm"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "peer": true
- },
- "node_modules/@rollup/rollup-linux-arm-musleabihf": {
- "version": "4.60.4",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz",
- "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==",
- "cpu": [
- "arm"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "peer": true
- },
- "node_modules/@rollup/rollup-linux-arm64-gnu": {
- "version": "4.60.4",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz",
- "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "peer": true
- },
- "node_modules/@rollup/rollup-linux-arm64-musl": {
- "version": "4.60.4",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz",
- "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "peer": true
- },
- "node_modules/@rollup/rollup-linux-loong64-gnu": {
- "version": "4.60.4",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz",
- "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==",
- "cpu": [
- "loong64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "peer": true
- },
- "node_modules/@rollup/rollup-linux-loong64-musl": {
- "version": "4.60.4",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz",
- "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==",
- "cpu": [
- "loong64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "peer": true
- },
- "node_modules/@rollup/rollup-linux-ppc64-gnu": {
- "version": "4.60.4",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz",
- "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==",
- "cpu": [
- "ppc64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "peer": true
- },
- "node_modules/@rollup/rollup-linux-ppc64-musl": {
- "version": "4.60.4",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz",
- "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==",
- "cpu": [
- "ppc64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "peer": true
- },
- "node_modules/@rollup/rollup-linux-riscv64-gnu": {
- "version": "4.60.4",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz",
- "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==",
- "cpu": [
- "riscv64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "peer": true
- },
- "node_modules/@rollup/rollup-linux-riscv64-musl": {
- "version": "4.60.4",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz",
- "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==",
- "cpu": [
- "riscv64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "peer": true
- },
- "node_modules/@rollup/rollup-linux-s390x-gnu": {
- "version": "4.60.4",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz",
- "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==",
- "cpu": [
- "s390x"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "peer": true
- },
- "node_modules/@rollup/rollup-linux-x64-gnu": {
- "version": "4.60.4",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz",
- "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "peer": true
- },
- "node_modules/@rollup/rollup-linux-x64-musl": {
- "version": "4.60.4",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz",
- "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "peer": true
- },
- "node_modules/@rollup/rollup-openbsd-x64": {
- "version": "4.60.4",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz",
- "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "openbsd"
- ],
- "peer": true
- },
- "node_modules/@rollup/rollup-openharmony-arm64": {
- "version": "4.60.4",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz",
- "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "openharmony"
- ],
- "peer": true
- },
- "node_modules/@rollup/rollup-win32-arm64-msvc": {
- "version": "4.60.4",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz",
- "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "peer": true
- },
- "node_modules/@rollup/rollup-win32-ia32-msvc": {
- "version": "4.60.4",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz",
- "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==",
- "cpu": [
- "ia32"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "peer": true
- },
- "node_modules/@rollup/rollup-win32-x64-gnu": {
- "version": "4.60.4",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz",
- "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "peer": true
- },
- "node_modules/@rollup/rollup-win32-x64-msvc": {
- "version": "4.60.4",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz",
- "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "peer": true
- },
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
@@ -3375,9 +3000,9 @@
"peer": true
},
"node_modules/hono": {
- "version": "4.12.19",
- "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.19.tgz",
- "integrity": "sha512-xa3eYXYXx68XTT4hZ7dRzsXBhaq85ToSrlUJNoR0gwz/1Ap/CNwX47wfvV7pc/xWhjKVVkLT7zBJy8chhNguqQ==",
+ "version": "4.12.23",
+ "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.23.tgz",
+ "integrity": "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==",
"license": "MIT",
"peer": true,
"engines": {
@@ -5417,53 +5042,6 @@
"@rolldown/binding-win32-x64-msvc": "1.0.1"
}
},
- "node_modules/rollup": {
- "version": "4.60.4",
- "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz",
- "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "peer": true,
- "dependencies": {
- "@types/estree": "1.0.8"
- },
- "bin": {
- "rollup": "dist/bin/rollup"
- },
- "engines": {
- "node": ">=18.0.0",
- "npm": ">=8.0.0"
- },
- "optionalDependencies": {
- "@rollup/rollup-android-arm-eabi": "4.60.4",
- "@rollup/rollup-android-arm64": "4.60.4",
- "@rollup/rollup-darwin-arm64": "4.60.4",
- "@rollup/rollup-darwin-x64": "4.60.4",
- "@rollup/rollup-freebsd-arm64": "4.60.4",
- "@rollup/rollup-freebsd-x64": "4.60.4",
- "@rollup/rollup-linux-arm-gnueabihf": "4.60.4",
- "@rollup/rollup-linux-arm-musleabihf": "4.60.4",
- "@rollup/rollup-linux-arm64-gnu": "4.60.4",
- "@rollup/rollup-linux-arm64-musl": "4.60.4",
- "@rollup/rollup-linux-loong64-gnu": "4.60.4",
- "@rollup/rollup-linux-loong64-musl": "4.60.4",
- "@rollup/rollup-linux-ppc64-gnu": "4.60.4",
- "@rollup/rollup-linux-ppc64-musl": "4.60.4",
- "@rollup/rollup-linux-riscv64-gnu": "4.60.4",
- "@rollup/rollup-linux-riscv64-musl": "4.60.4",
- "@rollup/rollup-linux-s390x-gnu": "4.60.4",
- "@rollup/rollup-linux-x64-gnu": "4.60.4",
- "@rollup/rollup-linux-x64-musl": "4.60.4",
- "@rollup/rollup-openbsd-x64": "4.60.4",
- "@rollup/rollup-openharmony-arm64": "4.60.4",
- "@rollup/rollup-win32-arm64-msvc": "4.60.4",
- "@rollup/rollup-win32-ia32-msvc": "4.60.4",
- "@rollup/rollup-win32-x64-gnu": "4.60.4",
- "@rollup/rollup-win32-x64-msvc": "4.60.4",
- "fsevents": "~2.3.2"
- }
- },
"node_modules/router": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
diff --git a/ui/src/apps/get-me/App.tsx b/ui/src/apps/get-me/App.tsx
index a20aae17c5..c181fcab90 100644
--- a/ui/src/apps/get-me/App.tsx
+++ b/ui/src/apps/get-me/App.tsx
@@ -1,4 +1,5 @@
import { StrictMode, useState } from "react";
+import type React from "react";
import { createRoot } from "react-dom/client";
import { Avatar, Box, Text, Link, Heading, Spinner } from "@primer/react";
import {
@@ -62,8 +63,20 @@ function AvatarWithFallback({ src, login, size }: { src?: string; login: string;
);
}
-function UserCard({ user }: { user: UserData }) {
+function UserCard({
+ user,
+ onOpenLink,
+}: {
+ user: UserData;
+ onOpenLink?: (url: string) => void;
+}) {
const d = user.details || {};
+ const handleClick =
+ onOpenLink &&
+ ((url: string) => (e: React.MouseEvent) => {
+ e.preventDefault();
+ onOpenLink(url);
+ });
return (
- {d.blog}
+
+ {d.blog}
+
>
)}
{d.email && (
@@ -140,41 +159,39 @@ function UserCard({ user }: { user: UserData }) {
}
function GetMeApp() {
- const { error, toolResult } = useMcpApp({
+ const { error, toolResult, hostContext, openLink } = useMcpApp({
appName: "github-mcp-server-get-me",
});
- if (error) {
- return Error: {error.message};
- }
-
- if (!toolResult) {
- return (
-
-
- Loading user data...
-
- );
- }
-
- // Parse user data from tool result
- const textContent = toolResult.content?.find((c: { type: string }) => c.type === "text");
- if (!textContent || !("text" in textContent)) {
- return No user data in response;
- }
+ const content = (() => {
+ if (error) {
+ return Error: {error.message};
+ }
+ if (!toolResult) {
+ return (
+
+
+ Loading user data...
+
+ );
+ }
+ const textContent = toolResult.content?.find((c: { type: string }) => c.type === "text");
+ if (!textContent || !("text" in textContent)) {
+ return No user data in response;
+ }
+ try {
+ const userData = JSON.parse(textContent.text as string) as UserData;
+ return void openLink(url)} />;
+ } catch {
+ return Failed to parse user data;
+ }
+ })();
- try {
- const userData = JSON.parse(textContent.text as string) as UserData;
- return ;
- } catch {
- return Failed to parse user data;
- }
+ return {content};
}
createRoot(document.getElementById("root")!).render(
-
-
-
+
);
diff --git a/ui/src/apps/issue-write/App.tsx b/ui/src/apps/issue-write/App.tsx
index de72b0a78a..6c46b8c081 100644
--- a/ui/src/apps/issue-write/App.tsx
+++ b/ui/src/apps/issue-write/App.tsx
@@ -33,12 +33,14 @@ function SuccessView({
repo,
submittedTitle,
isUpdate,
+ openLink,
}: {
issue: IssueResult;
owner: string;
repo: string;
submittedTitle: string;
isUpdate: boolean;
+ openLink: (url: string) => Promise;
}) {
const issueUrl = issue.html_url || issue.url || issue.URL || "#";
@@ -87,6 +89,14 @@ function SuccessView({
href={issueUrl}
target="_blank"
rel="noopener noreferrer"
+ onClick={(e) => {
+ // MCP Apps run in a sandboxed iframe where a plain anchor may be
+ // blocked, so route the click through the host's open-link
+ // capability (falls back to window.open).
+ e.preventDefault();
+ if (issueUrl === "#") return;
+ void openLink(issueUrl);
+ }}
style={{
fontWeight: 600,
fontSize: "14px",
@@ -121,7 +131,7 @@ function CreateIssueApp() {
const [error, setError] = useState(null);
const [successIssue, setSuccessIssue] = useState(null);
- const { app, error: appError, toolInput, callTool } = useMcpApp({
+ const { app, error: appError, toolInput, callTool, hostContext, setModelContext, openLink } = useMcpApp({
appName: "github-mcp-server-issue-write",
});
@@ -152,6 +162,7 @@ function CreateIssueApp() {
try {
const params: Record = {
+ ...(toolInput as Record | undefined),
method: isUpdateMode ? "update" : "create",
owner,
repo,
@@ -181,6 +192,19 @@ function CreateIssueApp() {
try {
const issueData = JSON.parse(textContent.text as string);
setSuccessIssue(issueData);
+ // Per the MCP Apps 2026-01-26 spec, push the created/updated issue
+ // into the model's context so subsequent agent turns have it.
+ void setModelContext({
+ structuredContent: issueData,
+ content: [
+ {
+ type: "text",
+ text: isUpdateMode
+ ? `Issue #${issueNumber} in ${owner}/${repo} was updated by the user via the issue-write view.`
+ : `A new issue was created in ${owner}/${repo} by the user via the issue-write view.`,
+ },
+ ],
+ });
} catch {
setSuccessIssue({ title, body });
}
@@ -191,8 +215,9 @@ function CreateIssueApp() {
} finally {
setIsSubmitting(false);
}
- }, [title, body, owner, repo, isUpdateMode, issueNumber, callTool]);
+ }, [title, body, owner, repo, isUpdateMode, issueNumber, toolInput, callTool, setModelContext]);
+ const body_node = (() => {
if (appError) {
return (
@@ -217,6 +242,7 @@ function CreateIssueApp() {
repo={repo}
submittedTitle={title}
isUpdate={isUpdateMode}
+ openLink={openLink}
/>
);
}
@@ -307,12 +333,13 @@ function CreateIssueApp() {
);
+ })();
+
+ return {body_node};
}
createRoot(document.getElementById("root")!).render(
-
-
-
+
);
diff --git a/ui/src/apps/pr-write/App.tsx b/ui/src/apps/pr-write/App.tsx
index f5ddbdf29d..245753a1bc 100644
--- a/ui/src/apps/pr-write/App.tsx
+++ b/ui/src/apps/pr-write/App.tsx
@@ -36,11 +36,13 @@ function SuccessView({
owner,
repo,
submittedTitle,
+ openLink,
}: {
pr: PRResult;
owner: string;
repo: string;
submittedTitle: string;
+ openLink: (url: string) => Promise;
}) {
const prUrl = pr.html_url || pr.url || pr.URL || "#";
@@ -89,6 +91,14 @@ function SuccessView({
href={prUrl}
target="_blank"
rel="noopener noreferrer"
+ onClick={(e) => {
+ // MCP Apps run in a sandboxed iframe where a plain anchor may be
+ // blocked, so route the click through the host's open-link
+ // capability (falls back to window.open).
+ e.preventDefault();
+ if (prUrl === "#") return;
+ void openLink(prUrl);
+ }}
style={{
fontWeight: 600,
fontSize: "14px",
@@ -126,7 +136,7 @@ function CreatePRApp() {
const [isDraft, setIsDraft] = useState(false);
const [maintainerCanModify, setMaintainerCanModify] = useState(true);
- const { app, error: appError, toolInput, callTool } = useMcpApp({
+ const { app, error: appError, toolInput, callTool, hostContext, setModelContext, openLink } = useMcpApp({
appName: "github-mcp-server-create-pull-request",
});
@@ -156,6 +166,7 @@ function CreatePRApp() {
try {
const result = await callTool("create_pull_request", {
+ ...(toolInput as Record | undefined),
owner, repo,
title: title.trim(),
body: body.trim(),
@@ -175,6 +186,17 @@ function CreatePRApp() {
if (textContent && textContent.type === "text" && textContent.text) {
const prData = JSON.parse(textContent.text);
setSuccessPR(prData);
+ // Push the new PR into the model context so subsequent agent
+ // turns can reference it (MCP Apps 2026-01-26 ui/update-model-context).
+ void setModelContext({
+ structuredContent: prData,
+ content: [
+ {
+ type: "text",
+ text: `A new pull request was created in ${owner}/${repo} by the user via the create-pull-request view.`,
+ },
+ ],
+ });
}
}
} catch (e) {
@@ -182,19 +204,19 @@ function CreatePRApp() {
} finally {
setIsSubmitting(false);
}
- }, [title, body, owner, repo, head, base, isDraft, maintainerCanModify, callTool]);
+ }, [title, body, owner, repo, head, base, isDraft, maintainerCanModify, toolInput, callTool, setModelContext]);
if (successPR) {
return (
-
-
+
+
);
}
if (!app && !appError) {
return (
-
+
@@ -204,14 +226,14 @@ function CreatePRApp() {
if (appError) {
return (
-
+
{appError.message}
);
}
return (
-
+
{
- // Set up theme data attributes for proper Primer theming
- const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
- const colorMode = prefersDark ? "dark" : "light";
+ // Prefer the host-supplied theme; fall back to the OS preference.
+ const colorMode =
+ hostTheme === "light" || hostTheme === "dark"
+ ? hostTheme
+ : window.matchMedia("(prefers-color-scheme: dark)").matches
+ ? "dark"
+ : "light";
document.body.setAttribute("data-color-mode", colorMode);
document.body.setAttribute("data-light-theme", "light");
document.body.setAttribute("data-dark-theme", "dark");
- }, []);
+ }, [hostTheme]);
+
+ // Project the host's standardized CSS variables onto the root so child
+ // components can consume them via `var(--color-...)`. We rely on Primer's
+ // own defaults when the host does not supply variables.
+ const styleVars = useMemo(() => {
+ if (!hostVariables) return undefined;
+ const out: Record = {};
+ for (const [key, value] of Object.entries(hostVariables)) {
+ if (typeof value === "string") out[key] = value;
+ }
+ return out as CSSProperties;
+ }, [hostVariables]);
+
+ const colorMode =
+ hostTheme === "light" || hostTheme === "dark" ? hostTheme : "auto";
return (
-
+
-
+
{children}
diff --git a/ui/src/hooks/useMcpApp.ts b/ui/src/hooks/useMcpApp.ts
index 54bfa791a7..cf386520f0 100644
--- a/ui/src/hooks/useMcpApp.ts
+++ b/ui/src/hooks/useMcpApp.ts
@@ -1,11 +1,23 @@
import { useApp as useExtApp } from "@modelcontextprotocol/ext-apps/react";
-import type { App } from "@modelcontextprotocol/ext-apps";
+import type {
+ App,
+ McpUiDisplayMode,
+ McpUiHostContext,
+ McpUiUpdateModelContextRequest,
+} from "@modelcontextprotocol/ext-apps";
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
-import { useState, useCallback } from "react";
+import { useState, useCallback, useEffect } from "react";
interface UseMcpAppOptions {
appName: string;
appVersion?: string;
+ /**
+ * Display modes this view supports. Per the MCP Apps 2026-01-26 spec, a
+ * view MUST declare every display mode it supports during initialization.
+ * Defaults to ["inline"] which is the only mode the bundled github-mcp-server
+ * views currently render.
+ */
+ availableDisplayModes?: McpUiDisplayMode[];
onToolResult?: (result: CallToolResult) => void;
onToolInput?: (input: Record) => void;
}
@@ -15,21 +27,38 @@ interface UseMcpAppReturn {
error: Error | null;
toolResult: CallToolResult | null;
toolInput: Record | null;
+ hostContext: McpUiHostContext | undefined;
callTool: (name: string, args: Record) => Promise;
+ /**
+ * Sends `ui/update-model-context` so the agent's next turn sees the
+ * supplied structured content / blocks. No-op when the app isn't connected.
+ */
+ setModelContext: (
+ params: McpUiUpdateModelContextRequest["params"]
+ ) => Promise;
+ /**
+ * Sends `ui/open-link` so the host opens an external URL in the user's
+ * browser. Falls back to `window.open` when the app isn't connected.
+ */
+ openLink: (url: string) => Promise;
}
export function useMcpApp({
appName,
appVersion = "1.0.0",
+ availableDisplayModes = ["inline"],
onToolResult,
onToolInput,
}: UseMcpAppOptions): UseMcpAppReturn {
const [toolResult, setToolResult] = useState(null);
const [toolInput, setToolInput] = useState | null>(null);
+ const [hostContext, setHostContext] = useState(undefined);
+ // The SDK's autoResize=true installs a ResizeObserver that emits
+ // `ui/notifications/size-changed` automatically; no manual wiring needed.
const { app, error } = useExtApp({
appInfo: { name: appName, version: appVersion },
- capabilities: {},
+ capabilities: { availableDisplayModes },
autoResize: true,
strict: import.meta.env.DEV,
onAppCreated: (app) => {
@@ -42,10 +71,19 @@ export function useMcpApp({
setToolInput(args);
onToolInput?.(args);
};
+ app.onhostcontextchanged = (params) => {
+ setHostContext((prev) => ({ ...(prev ?? {}), ...params }));
+ };
app.onerror = console.error;
},
});
+ useEffect(() => {
+ if (!app) return;
+ const initial = app.getHostContext();
+ if (initial) setHostContext(initial);
+ }, [app]);
+
const callTool = useCallback(
async (name: string, args: Record) => {
if (!app) throw new Error("App not connected");
@@ -54,5 +92,38 @@ export function useMcpApp({
[app]
);
- return { app, error, toolResult, toolInput, callTool };
+ const setModelContext = useCallback(
+ async (params) => {
+ if (!app) return;
+ await app.updateModelContext(params);
+ },
+ [app]
+ );
+
+ const openLink = useCallback(
+ async (url) => {
+ if (!app) {
+ window.open(url, "_blank", "noopener,noreferrer");
+ return;
+ }
+ const result = await app.openLink({ url });
+ // The host may deny the request (e.g. blocked domain or user cancelled).
+ // Fall back to a direct window.open so the link still works.
+ if (result?.isError) {
+ window.open(url, "_blank", "noopener,noreferrer");
+ }
+ },
+ [app]
+ );
+
+ return {
+ app,
+ error,
+ toolResult,
+ toolInput,
+ hostContext,
+ callTool,
+ setModelContext,
+ openLink,
+ };
}