diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml
deleted file mode 100644
index 92524ea17..000000000
--- a/.github/workflows/conformance.yml
+++ /dev/null
@@ -1,69 +0,0 @@
-name: Conformance Test
-
-on:
- pull_request:
-
-permissions:
- contents: read
-
-jobs:
- conformance:
- runs-on: ubuntu-latest
-
- steps:
- - name: Check out code
- uses: actions/checkout@v6
- with:
- # Fetch full history to access merge-base
- fetch-depth: 0
-
- - name: Set up Go
- uses: actions/setup-go@v6
- with:
- go-version-file: "go.mod"
-
- - name: Download dependencies
- run: go mod download
-
- - name: Run conformance test
- id: conformance
- run: |
- # Run conformance test, capture stdout for summary
- script/conformance-test > conformance-summary.txt 2>&1 || true
-
- # Output the summary
- cat conformance-summary.txt
-
- # Check result
- if grep -q "RESULT: ALL TESTS PASSED" conformance-summary.txt; then
- echo "status=passed" >> $GITHUB_OUTPUT
- else
- echo "status=differences" >> $GITHUB_OUTPUT
- fi
-
- - name: Generate Job Summary
- run: |
- # Add the full markdown report to the job summary
- echo "# MCP Server Conformance Report" >> $GITHUB_STEP_SUMMARY
- echo "" >> $GITHUB_STEP_SUMMARY
- echo "Comparing PR branch against merge-base with \`origin/main\`" >> $GITHUB_STEP_SUMMARY
- echo "" >> $GITHUB_STEP_SUMMARY
-
- # Extract and append the report content (skip the header since we added our own)
- tail -n +5 conformance-report/CONFORMANCE_REPORT.md >> $GITHUB_STEP_SUMMARY
-
- echo "" >> $GITHUB_STEP_SUMMARY
- echo "---" >> $GITHUB_STEP_SUMMARY
- echo "" >> $GITHUB_STEP_SUMMARY
-
- # Add interpretation note
- if [ "${{ steps.conformance.outputs.status }}" = "passed" ]; then
- echo "✅ **All conformance tests passed** - No behavioral differences detected." >> $GITHUB_STEP_SUMMARY
- else
- echo "⚠️ **Differences detected** - Review the diffs above to ensure changes are intentional." >> $GITHUB_STEP_SUMMARY
- echo "" >> $GITHUB_STEP_SUMMARY
- echo "Common expected differences:" >> $GITHUB_STEP_SUMMARY
- echo "- New tools/toolsets added" >> $GITHUB_STEP_SUMMARY
- echo "- Tool descriptions updated" >> $GITHUB_STEP_SUMMARY
- echo "- Capability changes (intentional improvements)" >> $GITHUB_STEP_SUMMARY
- fi
diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml
index 9fca37208..181a99560 100644
--- a/.github/workflows/go.yml
+++ b/.github/workflows/go.yml
@@ -14,6 +14,14 @@ jobs:
runs-on: ${{ matrix.os }}
steps:
+ - name: Force git to use LF
+ # This step is required on Windows to work around go mod tidy -diff issues caused by CRLF line endings.
+ # TODO: replace with a checkout option when https://github.com/actions/checkout/issues/226 is implemented
+ if: runner.os == 'Windows'
+ run: |
+ git config --global core.autocrlf false
+ git config --global core.eol lf
+
- name: Check out code
uses: actions/checkout@v6
@@ -22,8 +30,8 @@ jobs:
with:
go-version-file: "go.mod"
- - name: Download dependencies
- run: go mod download
+ - name: Tidy dependencies
+ run: go mod tidy -diff
- name: Run unit tests
run: script/test
diff --git a/.github/workflows/mcp-diff.yml b/.github/workflows/mcp-diff.yml
new file mode 100644
index 000000000..9c9c34b3a
--- /dev/null
+++ b/.github/workflows/mcp-diff.yml
@@ -0,0 +1,70 @@
+name: MCP Server Diff
+
+on:
+ pull_request:
+ push:
+ branches: [main]
+ tags: ['v*']
+
+permissions:
+ contents: read
+
+jobs:
+ mcp-diff:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Check out code
+ uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+
+ - name: Run MCP Server Diff
+ uses: SamMorrowDrums/mcp-server-diff@v2
+ with:
+ setup_go: "true"
+ install_command: go mod download
+ start_command: go run ./cmd/github-mcp-server stdio
+ env_vars: |
+ GITHUB_PERSONAL_ACCESS_TOKEN=test-token
+ configurations: |
+ [
+ {"name": "default", "args": ""},
+ {"name": "read-only", "args": "--read-only"},
+ {"name": "dynamic-toolsets", "args": "--dynamic-toolsets"},
+ {"name": "read-only+dynamic", "args": "--read-only --dynamic-toolsets"},
+ {"name": "toolsets-repos", "args": "--toolsets=repos"},
+ {"name": "toolsets-issues", "args": "--toolsets=issues"},
+ {"name": "toolsets-pull_requests", "args": "--toolsets=pull_requests"},
+ {"name": "toolsets-repos,issues", "args": "--toolsets=repos,issues"},
+ {"name": "toolsets-all", "args": "--toolsets=all"},
+ {"name": "tools-get_me", "args": "--tools=get_me"},
+ {"name": "tools-get_me,list_issues", "args": "--tools=get_me,list_issues"},
+ {"name": "toolsets-repos+read-only", "args": "--toolsets=repos --read-only"},
+ {"name": "toolsets-all+dynamic", "args": "--toolsets=all --dynamic-toolsets"},
+ {"name": "toolsets-repos+dynamic", "args": "--toolsets=repos --dynamic-toolsets"},
+ {"name": "toolsets-repos,issues+dynamic", "args": "--toolsets=repos,issues --dynamic-toolsets"},
+ {
+ "name": "dynamic-tool-calls",
+ "args": "--dynamic-toolsets",
+ "custom_messages": [
+ {"id": 10, "name": "list_toolsets_before", "message": {"jsonrpc": "2.0", "id": 10, "method": "tools/call", "params": {"name": "list_available_toolsets", "arguments": {}}}},
+ {"id": 11, "name": "get_toolset_tools", "message": {"jsonrpc": "2.0", "id": 11, "method": "tools/call", "params": {"name": "get_toolset_tools", "arguments": {"toolset": "repos"}}}},
+ {"id": 12, "name": "enable_toolset", "message": {"jsonrpc": "2.0", "id": 12, "method": "tools/call", "params": {"name": "enable_toolset", "arguments": {"toolset": "repos"}}}},
+ {"id": 13, "name": "list_toolsets_after", "message": {"jsonrpc": "2.0", "id": 13, "method": "tools/call", "params": {"name": "list_available_toolsets", "arguments": {}}}}
+ ]
+ }
+ ]
+
+ - name: Add interpretation note
+ if: always()
+ run: |
+ echo "" >> $GITHUB_STEP_SUMMARY
+ echo "---" >> $GITHUB_STEP_SUMMARY
+ echo "" >> $GITHUB_STEP_SUMMARY
+ echo "ℹ️ **Note:** Differences may be intentional improvements." >> $GITHUB_STEP_SUMMARY
+ echo "" >> $GITHUB_STEP_SUMMARY
+ echo "Common expected differences:" >> $GITHUB_STEP_SUMMARY
+ echo "- New tools/toolsets added" >> $GITHUB_STEP_SUMMARY
+ echo "- Tool descriptions updated" >> $GITHUB_STEP_SUMMARY
+ echo "- Capability changes (intentional improvements)" >> $GITHUB_STEP_SUMMARY
diff --git a/Dockerfile b/Dockerfile
index 92ed52581..9d68a985a 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -14,7 +14,7 @@ RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
--mount=type=bind,target=. \
CGO_ENABLED=0 go build -ldflags="-s -w -X main.version=${VERSION} -X main.commit=$(git rev-parse HEAD) -X main.date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
- -o /bin/github-mcp-server cmd/github-mcp-server/main.go
+ -o /bin/github-mcp-server ./cmd/github-mcp-server
# Make a stage to run the app
FROM gcr.io/distroless/base-debian12
diff --git a/README.md b/README.md
index 6f1cda8fa..430c895dd 100644
--- a/README.md
+++ b/README.md
@@ -83,7 +83,7 @@ Alternatively, to manually configure VS Code, choose the appropriate JSON block
- **[GitHub Copilot in other IDEs](/docs/installation-guides/install-other-copilot-ides.md)** - Installation for JetBrains, Visual Studio, Eclipse, and Xcode with GitHub Copilot
- **[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 Open AI Codex
+- **[Codex](/docs/installation-guides/install-codex.md)** - Installation guide for OpenAI Codex
- **[Cursor](/docs/installation-guides/install-cursor.md)** - Installation guide for Cursor IDE
- **[Windsurf](/docs/installation-guides/install-windsurf.md)** - Installation guide for Windsurf IDE
- **[Rovo Dev CLI](/docs/installation-guides/install-rovo-dev-cli.md)** - Installation guide for Rovo Dev CLI
@@ -98,6 +98,49 @@ See [Remote Server Documentation](docs/remote-server.md) for full details on rem
When no toolsets are specified, [default toolsets](#default-toolset) are used.
+#### Insiders Mode
+
+> **Try new features early!** The remote server offers an insiders version with early access to new features and experimental tools.
+
+
+| Using URL Path | Using Header |
+
+|
+
+```json
+{
+ "servers": {
+ "github": {
+ "type": "http",
+ "url": "https://api.githubcopilot.com/mcp/insiders"
+ }
+ }
+}
+```
+
+ |
+
+
+```json
+{
+ "servers": {
+ "github": {
+ "type": "http",
+ "url": "https://api.githubcopilot.com/mcp/",
+ "headers": {
+ "X-MCP-Insiders": "true"
+ }
+ }
+ }
+}
+```
+
+ |
+
+
+
+See [Remote Server Documentation](docs/remote-server.md#insiders-mode) for more details and examples.
+
#### GitHub Enterprise
##### GitHub Enterprise Cloud with data residency (ghe.com)
@@ -343,10 +386,9 @@ If you don't have Docker, you can use `go build` to build the binary in the
The `github-mcp-server` binary includes a few CLI subcommands that are helpful for debugging and exploring the server.
- `github-mcp-server tool-search ""` searches tools by name, description, and input parameter names. Use `--max-results` to return more matches.
-Example:
-
+Example (color output requires a TTY; use `docker run -t` (or `-it`) when running in Docker):
```bash
-docker run -i --rm ghcr.io/github/github-mcp-server tool-search "issue" --max-results 5
+docker run -it --rm ghcr.io/github/github-mcp-server tool-search "issue" --max-results 5
github-mcp-server tool-search "issue" --max-results 5
```
@@ -481,6 +523,31 @@ To keep the default configuration and add additional toolsets:
GITHUB_TOOLSETS="default,stargazers" ./github-mcp-server
```
+### Insiders Mode
+
+The local GitHub MCP Server offers an insiders version with early access to new features and experimental tools.
+
+1. **Using Command Line Argument**:
+
+ ```bash
+ ./github-mcp-server --insider-mode
+ ```
+
+2. **Using Environment Variable**:
+
+ ```bash
+ GITHUB_INSIDER_MODE=true ./github-mcp-server
+ ```
+
+When using Docker:
+
+```bash
+docker run -i --rm \
+ -e GITHUB_PERSONAL_ACCESS_TOKEN= \
+ -e GITHUB_INSIDER_MODE=true \
+ ghcr.io/github/github-mcp-server
+```
+
### Available Toolsets
The following sets of tools are available:
@@ -523,108 +590,53 @@ The following sets of tools are available:
Actions
-- **cancel_workflow_run** - Cancel workflow run
+- **actions_get** - Get details of GitHub Actions resources (workflows, workflow runs, jobs, and artifacts)
- **Required OAuth Scopes**: `repo`
+ - `method`: The method to execute (string, required)
- `owner`: Repository owner (string, required)
- `repo`: Repository name (string, required)
- - `run_id`: The unique identifier of the workflow run (number, required)
+ - `resource_id`: The unique identifier of the resource. This will vary based on the "method" provided, so ensure you provide the correct ID:
+ - Provide a workflow ID or workflow file name (e.g. ci.yaml) for 'get_workflow' method.
+ - Provide a workflow run ID for 'get_workflow_run', 'get_workflow_run_usage', and 'get_workflow_run_logs_url' methods.
+ - Provide an artifact ID for 'download_workflow_run_artifact' method.
+ - Provide a job ID for 'get_workflow_job' method.
+ (string, required)
-- **delete_workflow_run_logs** - Delete workflow logs
+- **actions_list** - List GitHub Actions workflows in a repository
- **Required OAuth Scopes**: `repo`
+ - `method`: The action to perform (string, required)
- `owner`: Repository owner (string, required)
+ - `page`: Page number for pagination (default: 1) (number, optional)
+ - `per_page`: Results per page for pagination (default: 30, max: 100) (number, optional)
- `repo`: Repository name (string, required)
- - `run_id`: The unique identifier of the workflow run (number, required)
-
-- **download_workflow_run_artifact** - Download workflow artifact
+ - `resource_id`: The unique identifier of the resource. This will vary based on the "method" provided, so ensure you provide the correct ID:
+ - Do not provide any resource ID for 'list_workflows' method.
+ - Provide a workflow ID or workflow file name (e.g. ci.yaml) for 'list_workflow_runs' method, or omit to list all workflow runs in the repository.
+ - Provide a workflow run ID for 'list_workflow_jobs' and 'list_workflow_run_artifacts' methods.
+ (string, optional)
+ - `workflow_jobs_filter`: Filters for workflow jobs. **ONLY** used when method is 'list_workflow_jobs' (object, optional)
+ - `workflow_runs_filter`: Filters for workflow runs. **ONLY** used when method is 'list_workflow_runs' (object, optional)
+
+- **actions_run_trigger** - Trigger GitHub Actions workflow actions
- **Required OAuth Scopes**: `repo`
- - `artifact_id`: The unique identifier of the artifact (number, required)
+ - `inputs`: Inputs the workflow accepts. Only used for 'run_workflow' method. (object, optional)
+ - `method`: The method to execute (string, required)
- `owner`: Repository owner (string, required)
+ - `ref`: The git reference for the workflow. The reference can be a branch or tag name. Required for 'run_workflow' method. (string, optional)
- `repo`: Repository name (string, required)
+ - `run_id`: The ID of the workflow run. Required for all methods except 'run_workflow'. (number, optional)
+ - `workflow_id`: The workflow ID (numeric) or workflow file name (e.g., main.yml, ci.yaml). Required for 'run_workflow' method. (string, optional)
-- **get_job_logs** - Get job logs
+- **get_job_logs** - Get GitHub Actions workflow job logs
- **Required OAuth Scopes**: `repo`
- - `failed_only`: When true, gets logs for all failed jobs in run_id (boolean, optional)
- - `job_id`: The unique identifier of the workflow job (required for single job logs) (number, optional)
+ - `failed_only`: When true, gets logs for all failed jobs in the workflow run specified by run_id. Requires run_id to be provided. (boolean, optional)
+ - `job_id`: The unique identifier of the workflow job. Required when getting logs for a single job. (number, optional)
- `owner`: Repository owner (string, required)
- `repo`: Repository name (string, required)
- `return_content`: Returns actual log content instead of URLs (boolean, optional)
- - `run_id`: Workflow run ID (required when using failed_only) (number, optional)
+ - `run_id`: The unique identifier of the workflow run. Required when failed_only is true to get logs for all failed jobs in the run. (number, optional)
- `tail_lines`: Number of lines to return from the end of the log (number, optional)
-- **get_workflow_run** - Get workflow run
- - **Required OAuth Scopes**: `repo`
- - `owner`: Repository owner (string, required)
- - `repo`: Repository name (string, required)
- - `run_id`: The unique identifier of the workflow run (number, required)
-
-- **get_workflow_run_logs** - Get workflow run logs
- - **Required OAuth Scopes**: `repo`
- - `owner`: Repository owner (string, required)
- - `repo`: Repository name (string, required)
- - `run_id`: The unique identifier of the workflow run (number, required)
-
-- **get_workflow_run_usage** - Get workflow usage
- - **Required OAuth Scopes**: `repo`
- - `owner`: Repository owner (string, required)
- - `repo`: Repository name (string, required)
- - `run_id`: The unique identifier of the workflow run (number, required)
-
-- **list_workflow_jobs** - List workflow jobs
- - **Required OAuth Scopes**: `repo`
- - `filter`: Filters jobs by their completed_at timestamp (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)
- - `repo`: Repository name (string, required)
- - `run_id`: The unique identifier of the workflow run (number, required)
-
-- **list_workflow_run_artifacts** - List workflow artifacts
- - **Required OAuth Scopes**: `repo`
- - `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)
- - `repo`: Repository name (string, required)
- - `run_id`: The unique identifier of the workflow run (number, required)
-
-- **list_workflow_runs** - List workflow runs
- - **Required OAuth Scopes**: `repo`
- - `actor`: Returns someone's workflow runs. Use the login for the user who created the workflow run. (string, optional)
- - `branch`: Returns workflow runs associated with a branch. Use the name of the branch. (string, optional)
- - `event`: Returns workflow runs for a specific event type (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)
- - `repo`: Repository name (string, required)
- - `status`: Returns workflow runs with the check run status (string, optional)
- - `workflow_id`: The workflow ID or workflow file name (string, required)
-
-- **list_workflows** - List workflows
- - **Required OAuth Scopes**: `repo`
- - `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)
- - `repo`: Repository name (string, required)
-
-- **rerun_failed_jobs** - Rerun failed jobs
- - **Required OAuth Scopes**: `repo`
- - `owner`: Repository owner (string, required)
- - `repo`: Repository name (string, required)
- - `run_id`: The unique identifier of the workflow run (number, required)
-
-- **rerun_workflow_run** - Rerun workflow run
- - **Required OAuth Scopes**: `repo`
- - `owner`: Repository owner (string, required)
- - `repo`: Repository name (string, required)
- - `run_id`: The unique identifier of the workflow run (number, required)
-
-- **run_workflow** - Run workflow
- - **Required OAuth Scopes**: `repo`
- - `inputs`: Inputs the workflow accepts (object, optional)
- - `owner`: Repository owner (string, required)
- - `ref`: The git reference for the workflow. The reference can be a branch or tag name. (string, required)
- - `repo`: Repository name (string, required)
- - `workflow_id`: The workflow ID (numeric) or workflow file name (e.g., main.yml, ci.yaml) (string, required)
-
@@ -962,84 +974,43 @@ The following sets of tools are available:
Projects
-- **add_project_item** - Add project item
- - **Required OAuth Scopes**: `project`
- - `item_id`: The numeric ID of the issue or pull request to add to the project. (number, required)
- - `item_type`: The item's type, either issue or pull_request. (string, required)
- - `owner`: If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive. (string, required)
- - `owner_type`: Owner type (string, required)
- - `project_number`: The project's number. (number, required)
-
-- **delete_project_item** - Delete project item
- - **Required OAuth Scopes**: `project`
- - `item_id`: The internal project item ID to delete from the project (not the issue or pull request ID). (number, required)
- - `owner`: If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive. (string, required)
- - `owner_type`: Owner type (string, required)
- - `project_number`: The project's number. (number, required)
-
-- **get_project** - Get project
- - **Required OAuth Scopes**: `read:project`
- - **Accepted OAuth Scopes**: `project`, `read:project`
- - `owner`: If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive. (string, required)
- - `owner_type`: Owner type (string, required)
- - `project_number`: The project's number (number, required)
-
-- **get_project_field** - Get project field
+- **projects_get** - Get details of GitHub Projects resources
- **Required OAuth Scopes**: `read:project`
- **Accepted OAuth Scopes**: `project`, `read:project`
- - `field_id`: The field's id. (number, required)
- - `owner`: If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive. (string, required)
- - `owner_type`: Owner type (string, required)
- - `project_number`: The project's number. (number, required)
-
-- **get_project_item** - Get project item
- - **Required OAuth Scopes**: `read:project`
- - **Accepted OAuth Scopes**: `project`, `read:project`
- - `fields`: Specific list of field IDs to include in the response (e.g. ["102589", "985201", "169875"]). If not provided, only the title field is included. (string[], optional)
- - `item_id`: The item's ID. (number, required)
- - `owner`: If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive. (string, required)
- - `owner_type`: Owner type (string, required)
- - `project_number`: The project's number. (number, required)
-
-- **list_project_fields** - List project fields
- - **Required OAuth Scopes**: `read:project`
- - **Accepted OAuth Scopes**: `project`, `read:project`
- - `after`: Forward pagination cursor from previous pageInfo.nextCursor. (string, optional)
- - `before`: Backward pagination cursor from previous pageInfo.prevCursor (rare). (string, optional)
- - `owner`: If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive. (string, required)
- - `owner_type`: Owner type (string, required)
- - `per_page`: Results per page (max 50) (number, optional)
- - `project_number`: The project's number. (number, required)
-
-- **list_project_items** - List project items
- - **Required OAuth Scopes**: `read:project`
- - **Accepted OAuth Scopes**: `project`, `read:project`
- - `after`: Forward pagination cursor from previous pageInfo.nextCursor. (string, optional)
- - `before`: Backward pagination cursor from previous pageInfo.prevCursor (rare). (string, optional)
- - `fields`: Field IDs to include (e.g. ["102589", "985201"]). CRITICAL: Always provide to get field values. Without this, only titles returned. (string[], optional)
- - `owner`: If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive. (string, required)
- - `owner_type`: Owner type (string, required)
- - `per_page`: Results per page (max 50) (number, optional)
+ - `field_id`: The field's ID. Required for 'get_project_field' method. (number, optional)
+ - `fields`: Specific list of field IDs to include in the response when getting a project item (e.g. ["102589", "985201", "169875"]). If not provided, only the title field is included. Only used for 'get_project_item' method. (string[], optional)
+ - `item_id`: The item's ID. Required for 'get_project_item' method. (number, optional)
+ - `method`: The method to execute (string, required)
+ - `owner`: The 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)
- - `query`: Query string for advanced filtering of project items using GitHub's project filtering syntax. (string, optional)
-- **list_projects** - List projects
+- **projects_list** - List GitHub Projects resources
- **Required OAuth Scopes**: `read:project`
- **Accepted OAuth Scopes**: `project`, `read:project`
- `after`: Forward pagination cursor from previous pageInfo.nextCursor. (string, optional)
- `before`: Backward pagination cursor from previous pageInfo.prevCursor (rare). (string, optional)
- - `owner`: If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive. (string, required)
- - `owner_type`: Owner type (string, required)
+ - `fields`: Field IDs to include when listing project items (e.g. ["102589", "985201"]). CRITICAL: Always provide to get field values. Without this, only titles returned. Only used for 'list_project_items' method. (string[], optional)
+ - `method`: The action to perform (string, required)
+ - `owner`: The owner (user or organization login). The name is not case sensitive. (string, required)
+ - `owner_type`: Owner type (user or org). If not provided, will automatically try both. (string, optional)
- `per_page`: Results per page (max 50) (number, optional)
- - `query`: Filter projects by title text and open/closed state; permitted qualifiers: is:open, is:closed; examples: "roadmap is:open", "is:open feature planning". (string, optional)
+ - `project_number`: The project's number. Required for 'list_project_fields' and 'list_project_items' 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)
-- **update_project_item** - Update project item
+- **projects_write** - Modify GitHub Project items
- **Required OAuth Scopes**: `project`
- - `item_id`: The unique identifier of the project item. This is not the issue or pull request ID. (number, required)
- - `owner`: If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive. (string, required)
- - `owner_type`: Owner type (string, required)
+ - `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)
+ - `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)
- - `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"} (object, required)
+ - `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)
+ - `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)
diff --git a/cmd/mcpcurl/README.md b/cmd/mcpcurl/README.md
index 717ea207f..e1227d585 100644
--- a/cmd/mcpcurl/README.md
+++ b/cmd/mcpcurl/README.md
@@ -16,7 +16,7 @@ be executed against the configured MCP server.
## Installation
### Prerequisites
-- Go 1.21 or later
+- Go 1.24 or later
- Access to the GitHub MCP Server from either Docker or local build
### Build from Source
diff --git a/docs/installation-guides/install-claude.md b/docs/installation-guides/install-claude.md
index ff1b26d70..05e3c3739 100644
--- a/docs/installation-guides/install-claude.md
+++ b/docs/installation-guides/install-claude.md
@@ -29,6 +29,8 @@ echo -e ".env\n.mcp.json" >> .gitignore
### Remote Server Setup (Streamable HTTP)
> **Note**: For Claude Code versions **2.1.1 and newer**, use the `add-json` command format below. For older versions, see the [legacy command format](#for-older-versions-of-claude-code).
+>
+> **Windows / CLI note**: `claude mcp add-json` may return `Invalid input` when adding an HTTP server. If that happens, use the legacy `claude mcp add --transport http ...` command format below.
1. Run the following command in the terminal (not in Claude Code CLI):
```bash
@@ -95,6 +97,15 @@ With an environment variable:
claude mcp add --transport http github https://api.githubcopilot.com/mcp -H "Authorization: Bearer $(grep GITHUB_PAT .env | cut -d '=' -f2)"
```
+#### Windows (PowerShell)
+
+If you see `missing required argument 'name'`, put the server name immediately after `claude mcp add`:
+
+```powershell
+$pat = "YOUR_GITHUB_PAT"
+claude mcp add github --transport http https://api.githubcopilot.com/mcp/ -H "Authorization: Bearer $pat"
+```
+
---
## Claude Desktop
diff --git a/docs/remote-server.md b/docs/remote-server.md
index 039d094fe..b305271cf 100644
--- a/docs/remote-server.md
+++ b/docs/remote-server.md
@@ -67,6 +67,9 @@ The Remote GitHub MCP server has optional headers equivalent to the Local server
- `X-MCP-Lockdown`: Enables lockdown mode, hiding public issue details created by users without push access.
- Equivalent to `GITHUB_LOCKDOWN_MODE` env var for Local server.
- If this header is empty, "false", "f", "no", "n", "0", or "off" (ignoring whitespace and case), it will be interpreted as false. All other values are interpreted as true.
+- `X-MCP-Insiders`: Enables insiders mode for early access to new features.
+ - Equivalent to `GITHUB_INSIDER_MODE` env var or `--insider-mode` flag for Local server.
+ - If this header is empty, "false", "f", "no", "n", "0", or "off" (ignoring whitespace and case), it will be interpreted as false. All other values are interpreted as true.
> **Looking for examples?** See the [Server Configuration Guide](./server-configuration.md) for common recipes like minimal setups, read-only mode, and combining tools with toolsets.
@@ -84,18 +87,49 @@ Example:
}
```
+### Insiders Mode
+
+The remote GitHub MCP Server offers an insiders version with early access to new features and experimental tools. You can enable insiders mode in two ways:
+
+1. **Via URL path** - Append `/insiders` to the URL:
+
+ ```json
+ {
+ "type": "http",
+ "url": "https://api.githubcopilot.com/mcp/insiders"
+ }
+ ```
+
+2. **Via header** - Set the `X-MCP-Insiders` header to `true`:
+
+ ```json
+ {
+ "type": "http",
+ "url": "https://api.githubcopilot.com/mcp/",
+ "headers": {
+ "X-MCP-Insiders": "true"
+ }
+ }
+ ```
+
+Both methods can be combined with other path modifiers (like `/readonly`) and headers.
+
### URL Path Parameters
The Remote GitHub MCP server supports the following URL path patterns:
- `/` - Default toolset (see ["default" toolset](../README.md#default-toolset))
- `/readonly` - Default toolset in read-only mode
+- `/insiders` - Default toolset with insiders mode enabled
+- `/insiders/readonly` - Default toolset with insiders mode in read-only mode
- `/x/all` - All available toolsets
- `/x/all/readonly` - All available toolsets in read-only mode
+- `/x/all/insiders` - All available toolsets with insiders mode enabled
- `/x/{toolset}` - Single specific toolset
- `/x/{toolset}/readonly` - Single specific toolset in read-only mode
+- `/x/{toolset}/insiders` - Single specific toolset with insiders mode enabled
-Note: `{toolset}` can only be a single toolset, not a comma-separated list. To combine multiple toolsets, use the `X-MCP-Toolsets` header instead.
+Note: `{toolset}` can only be a single toolset, not a comma-separated list. To combine multiple toolsets, use the `X-MCP-Toolsets` header instead. Path modifiers like `/readonly` and `/insiders` can be combined with the `X-MCP-Insiders` or `X-MCP-Readonly` headers.
Example:
diff --git a/go.mod b/go.mod
index e50f52c72..10bbde9d1 100644
--- a/go.mod
+++ b/go.mod
@@ -3,7 +3,6 @@ module github.com/github/github-mcp-server
go 1.24.0
require (
- github.com/fatih/color v1.18.0
github.com/google/go-github/v79 v79.0.0
github.com/google/jsonschema-go v0.4.2
github.com/josephburnett/jd v1.9.2
@@ -16,28 +15,17 @@ require (
require (
github.com/aymerick/douceur v0.2.0 // indirect
- github.com/go-openapi/jsonpointer v0.19.5 // indirect
- github.com/go-openapi/swag v0.21.1 // indirect
- github.com/gorilla/css v1.0.1 // indirect
- github.com/josharian/intern v1.0.0 // indirect
- github.com/mailru/easyjson v0.7.7 // indirect
- github.com/mattn/go-colorable v0.1.13 // indirect
- github.com/mattn/go-isatty v0.0.20 // indirect
- github.com/stretchr/objx v0.5.2 // indirect
- github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 // indirect
- go.yaml.in/yaml/v3 v3.0.4 // indirect
- golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect
- golang.org/x/net v0.38.0 // indirect
- gopkg.in/yaml.v2 v2.4.0 // indirect
-)
-
-require (
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
+ github.com/go-openapi/jsonpointer v0.19.5 // indirect
+ github.com/go-openapi/swag v0.21.1 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0
github.com/google/go-querystring v1.1.0 // indirect
+ github.com/gorilla/css v1.0.1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
+ github.com/josharian/intern v1.0.0 // indirect
github.com/lithammer/fuzzysearch v1.1.8
+ github.com/mailru/easyjson v0.7.7 // indirect
github.com/modelcontextprotocol/go-sdk v1.2.0
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
@@ -49,11 +37,17 @@ require (
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10
+ github.com/stretchr/objx v0.5.2 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2
+ github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 // indirect
+ go.yaml.in/yaml/v3 v3.0.4 // indirect
+ golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect
+ golang.org/x/net v0.38.0 // indirect
golang.org/x/oauth2 v0.30.0 // indirect
golang.org/x/sys v0.31.0 // indirect
golang.org/x/text v0.28.0 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
+ gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
diff --git a/go.sum b/go.sum
index 89c8b1dda..b364f2ef3 100644
--- a/go.sum
+++ b/go.sum
@@ -6,8 +6,6 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
-github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
@@ -53,11 +51,6 @@ github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN
github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
-github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
-github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
-github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
-github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
-github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/modelcontextprotocol/go-sdk v1.2.0 h1:Y23co09300CEk8iZ/tMxIX1dVmKZkzoSBZOpJwUnc/s=
@@ -130,9 +123,7 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
diff --git a/internal/ghmcp/server.go b/internal/ghmcp/server.go
index 9e7dafcae..4f68cfd49 100644
--- a/internal/ghmcp/server.go
+++ b/internal/ghmcp/server.go
@@ -171,16 +171,31 @@ func NewMCPServer(cfg MCPServerConfig) (*mcp.Server, error) {
enabledToolsets := resolveEnabledToolsets(cfg)
- // For instruction generation, we need actual toolset names (not nil).
- // nil means "use defaults" in inventory, so expand it for instructions.
- instructionToolsets := enabledToolsets
- if instructionToolsets == nil {
- instructionToolsets = github.GetDefaultToolsetIDs()
+ // Create feature checker
+ featureChecker := createFeatureChecker(cfg.EnabledFeatures)
+
+ // Build and register the tool/resource/prompt inventory
+ inventoryBuilder := github.NewInventory(cfg.Translator).
+ WithDeprecatedAliases(github.DeprecatedToolAliases).
+ WithReadOnly(cfg.ReadOnly).
+ WithToolsets(enabledToolsets).
+ WithTools(cfg.EnabledTools).
+ WithFeatureChecker(featureChecker).
+ WithServerInstructions()
+
+ // Apply token scope filtering if scopes are known (for PAT filtering)
+ if cfg.TokenScopes != nil {
+ inventoryBuilder = inventoryBuilder.WithFilter(github.CreateToolScopeFilter(cfg.TokenScopes))
+ }
+
+ inventory, err := inventoryBuilder.Build()
+ if err != nil {
+ return nil, fmt.Errorf("failed to build inventory: %w", err)
}
// Create the MCP server
serverOpts := &mcp.ServerOptions{
- Instructions: github.GenerateInstructions(instructionToolsets),
+ Instructions: inventory.Instructions(),
Logger: cfg.Logger,
CompletionHandler: github.CompletionsHandler(func(_ context.Context) (*gogithub.Client, error) {
return clients.rest, nil
@@ -203,9 +218,6 @@ func NewMCPServer(cfg MCPServerConfig) (*mcp.Server, error) {
ghServer.AddReceivingMiddleware(addGitHubAPIErrorToContext)
ghServer.AddReceivingMiddleware(addUserAgentsMiddleware(cfg, clients.rest, clients.gqlHTTP))
- // Create feature checker
- featureChecker := createFeatureChecker(cfg.EnabledFeatures)
-
// Create dependencies for tool handlers
deps := github.NewBaseDeps(
clients.rest,
@@ -228,24 +240,6 @@ func NewMCPServer(cfg MCPServerConfig) (*mcp.Server, error) {
}
})
- // Build and register the tool/resource/prompt inventory
- inventoryBuilder := github.NewInventory(cfg.Translator).
- WithDeprecatedAliases(github.DeprecatedToolAliases).
- WithReadOnly(cfg.ReadOnly).
- WithToolsets(enabledToolsets).
- WithTools(cfg.EnabledTools).
- WithFeatureChecker(featureChecker)
-
- // Apply token scope filtering if scopes are known (for PAT filtering)
- if cfg.TokenScopes != nil {
- inventoryBuilder = inventoryBuilder.WithFilter(github.CreateToolScopeFilter(cfg.TokenScopes))
- }
-
- inventory, err := inventoryBuilder.Build()
- if err != nil {
- return nil, fmt.Errorf("failed to build inventory: %w", err)
- }
-
if unrecognized := inventory.UnrecognizedToolsets(); len(unrecognized) > 0 {
fmt.Fprintf(os.Stderr, "Warning: unrecognized toolsets ignored: %s\n", strings.Join(unrecognized, ", "))
}
diff --git a/pkg/github/__toolsnaps__/assign_copilot_to_issue.snap b/pkg/github/__toolsnaps__/assign_copilot_to_issue.snap
index 76a122f16..9c105267b 100644
--- a/pkg/github/__toolsnaps__/assign_copilot_to_issue.snap
+++ b/pkg/github/__toolsnaps__/assign_copilot_to_issue.snap
@@ -23,8 +23,8 @@
"type": "string"
},
"custom_instructions": {
- "type": "string",
- "description": "Optional custom instructions to guide the agent beyond the issue body. Use this to provide additional context, constraints, or guidance that is not captured in the issue description"
+ "description": "Optional custom instructions to guide the agent beyond the issue body. Use this to provide additional context, constraints, or guidance that is not captured in the issue description",
+ "type": "string"
},
"issue_number": {
"description": "Issue number",
diff --git a/pkg/github/__toolsnaps__/projects_get.snap b/pkg/github/__toolsnaps__/projects_get.snap
index 9ccb1e75f..cb5013d74 100644
--- a/pkg/github/__toolsnaps__/projects_get.snap
+++ b/pkg/github/__toolsnaps__/projects_get.snap
@@ -31,11 +31,11 @@
"type": "string"
},
"owner": {
- "description": "If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive.",
+ "description": "The owner (user or organization login). The name is not case sensitive.",
"type": "string"
},
"owner_type": {
- "description": "Owner type",
+ "description": "Owner type (user or org). If not provided, will be automatically detected.",
"enum": [
"user",
"org"
@@ -49,7 +49,6 @@
},
"required": [
"method",
- "owner_type",
"owner",
"project_number"
],
diff --git a/pkg/github/__toolsnaps__/projects_list.snap b/pkg/github/__toolsnaps__/projects_list.snap
index 84e964a1d..f12452b5a 100644
--- a/pkg/github/__toolsnaps__/projects_list.snap
+++ b/pkg/github/__toolsnaps__/projects_list.snap
@@ -31,11 +31,11 @@
"type": "string"
},
"owner": {
- "description": "If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive.",
+ "description": "The owner (user or organization login). The name is not case sensitive.",
"type": "string"
},
"owner_type": {
- "description": "Owner type",
+ "description": "Owner type (user or org). If not provided, will automatically try both.",
"enum": [
"user",
"org"
@@ -57,7 +57,6 @@
},
"required": [
"method",
- "owner_type",
"owner"
],
"type": "object"
diff --git a/pkg/github/__toolsnaps__/projects_write.snap b/pkg/github/__toolsnaps__/projects_write.snap
index 759f79579..d2d871bcd 100644
--- a/pkg/github/__toolsnaps__/projects_write.snap
+++ b/pkg/github/__toolsnaps__/projects_write.snap
@@ -6,10 +6,22 @@
"description": "Add, update, or delete project items in a GitHub Project.",
"inputSchema": {
"properties": {
+ "issue_number": {
+ "description": "The issue number (use when item_type is 'issue' for 'add_project_item' method). Provide either issue_number or pull_request_number.",
+ "type": "number"
+ },
"item_id": {
- "description": "The project item ID. Required for 'update_project_item' and 'delete_project_item' methods. For add_project_item, this is the numeric ID of the issue or pull request to add.",
+ "description": "The project item ID. Required for 'update_project_item' and 'delete_project_item' methods.",
"type": "number"
},
+ "item_owner": {
+ "description": "The owner (user or organization) of the repository containing the issue or pull request. Required for 'add_project_item' method.",
+ "type": "string"
+ },
+ "item_repo": {
+ "description": "The name of the repository containing the issue or pull request. Required for 'add_project_item' method.",
+ "type": "string"
+ },
"item_type": {
"description": "The item's type, either issue or pull_request. Required for 'add_project_item' method.",
"enum": [
@@ -28,11 +40,11 @@
"type": "string"
},
"owner": {
- "description": "If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive.",
+ "description": "The project owner (user or organization login). The name is not case sensitive.",
"type": "string"
},
"owner_type": {
- "description": "Owner type",
+ "description": "Owner type (user or org). If not provided, will be automatically detected.",
"enum": [
"user",
"org"
@@ -43,6 +55,10 @@
"description": "The project's number.",
"type": "number"
},
+ "pull_request_number": {
+ "description": "The pull request number (use when item_type is 'pull_request' for 'add_project_item' method). Provide either issue_number or pull_request_number.",
+ "type": "number"
+ },
"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"
@@ -50,7 +66,6 @@
},
"required": [
"method",
- "owner_type",
"owner",
"project_number"
],
diff --git a/pkg/github/actions.go b/pkg/github/actions.go
index 14cb8028c..2fe2504ec 100644
--- a/pkg/github/actions.go
+++ b/pkg/github/actions.go
@@ -26,10 +26,14 @@ const (
DescriptionRepositoryName = "Repository name"
)
-// FeatureFlagConsolidatedActions is the feature flag that disables individual actions tools
-// in favor of the consolidated actions tools.
+// FeatureFlagConsolidatedActions is the legacy feature flag (deprecated, no longer used).
+// Kept for documentation purposes only.
const FeatureFlagConsolidatedActions = "remote_mcp_consolidated_actions"
+// FeatureFlagHoldbackConsolidatedActions is the feature flag that, when enabled, reverts to
+// individual actions tools instead of the consolidated actions tools.
+const FeatureFlagHoldbackConsolidatedActions = "mcp_holdback_consolidated_actions"
+
// Method constants for consolidated actions tools
const (
actionsMethodListWorkflows = "list_workflows"
@@ -117,7 +121,7 @@ func ListWorkflows(t translations.TranslationHelperFunc) inventory.ServerTool {
return utils.NewToolResultText(string(r)), nil, nil
},
)
- tool.FeatureFlagDisable = FeatureFlagConsolidatedActions
+ tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedActions
return tool
}
@@ -272,7 +276,7 @@ func ListWorkflowRuns(t translations.TranslationHelperFunc) inventory.ServerTool
return utils.NewToolResultText(string(r)), nil, nil
},
)
- tool.FeatureFlagDisable = FeatureFlagConsolidatedActions
+ tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedActions
return tool
}
@@ -385,7 +389,7 @@ func RunWorkflow(t translations.TranslationHelperFunc) inventory.ServerTool {
return utils.NewToolResultText(string(r)), nil, nil
},
)
- tool.FeatureFlagDisable = FeatureFlagConsolidatedActions
+ tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedActions
return tool
}
@@ -454,7 +458,7 @@ func GetWorkflowRun(t translations.TranslationHelperFunc) inventory.ServerTool {
return utils.NewToolResultText(string(r)), nil, nil
},
)
- tool.FeatureFlagDisable = FeatureFlagConsolidatedActions
+ tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedActions
return tool
}
@@ -533,7 +537,7 @@ func GetWorkflowRunLogs(t translations.TranslationHelperFunc) inventory.ServerTo
return utils.NewToolResultText(string(r)), nil, nil
},
)
- tool.FeatureFlagDisable = FeatureFlagConsolidatedActions
+ tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedActions
return tool
}
@@ -634,7 +638,7 @@ func ListWorkflowJobs(t translations.TranslationHelperFunc) inventory.ServerTool
return utils.NewToolResultText(string(r)), nil, nil
},
)
- tool.FeatureFlagDisable = FeatureFlagConsolidatedActions
+ tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedActions
return tool
}
@@ -746,7 +750,7 @@ func GetJobLogs(t translations.TranslationHelperFunc) inventory.ServerTool {
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
},
)
- tool.FeatureFlagDisable = FeatureFlagConsolidatedActions
+ tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedActions
return tool
}
@@ -976,7 +980,7 @@ func RerunWorkflowRun(t translations.TranslationHelperFunc) inventory.ServerTool
return utils.NewToolResultText(string(r)), nil, nil
},
)
- tool.FeatureFlagDisable = FeatureFlagConsolidatedActions
+ tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedActions
return tool
}
@@ -1052,7 +1056,7 @@ func RerunFailedJobs(t translations.TranslationHelperFunc) inventory.ServerTool
return utils.NewToolResultText(string(r)), nil, nil
},
)
- tool.FeatureFlagDisable = FeatureFlagConsolidatedActions
+ tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedActions
return tool
}
@@ -1130,7 +1134,7 @@ func CancelWorkflowRun(t translations.TranslationHelperFunc) inventory.ServerToo
return utils.NewToolResultText(string(r)), nil, nil
},
)
- tool.FeatureFlagDisable = FeatureFlagConsolidatedActions
+ tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedActions
return tool
}
@@ -1211,7 +1215,7 @@ func ListWorkflowRunArtifacts(t translations.TranslationHelperFunc) inventory.Se
return utils.NewToolResultText(string(r)), nil, nil
},
)
- tool.FeatureFlagDisable = FeatureFlagConsolidatedActions
+ tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedActions
return tool
}
@@ -1289,7 +1293,7 @@ func DownloadWorkflowRunArtifact(t translations.TranslationHelperFunc) inventory
return utils.NewToolResultText(string(r)), nil, nil
},
)
- tool.FeatureFlagDisable = FeatureFlagConsolidatedActions
+ tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedActions
return tool
}
@@ -1366,7 +1370,7 @@ func DeleteWorkflowRunLogs(t translations.TranslationHelperFunc) inventory.Serve
return utils.NewToolResultText(string(r)), nil, nil
},
)
- tool.FeatureFlagDisable = FeatureFlagConsolidatedActions
+ tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedActions
return tool
}
@@ -1435,7 +1439,7 @@ func GetWorkflowRunUsage(t translations.TranslationHelperFunc) inventory.ServerT
return utils.NewToolResultText(string(r)), nil, nil
},
)
- tool.FeatureFlagDisable = FeatureFlagConsolidatedActions
+ tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedActions
return tool
}
@@ -1631,7 +1635,7 @@ Use this tool to list workflows in a repository, or list workflow runs, jobs, an
}
},
)
- tool.FeatureFlagEnable = FeatureFlagConsolidatedActions
+ tool.FeatureFlagDisable = FeatureFlagHoldbackConsolidatedActions
return tool
}
@@ -1740,7 +1744,7 @@ Use this tool to get details about individual workflows, workflow runs, jobs, an
}
},
)
- tool.FeatureFlagEnable = FeatureFlagConsolidatedActions
+ tool.FeatureFlagDisable = FeatureFlagHoldbackConsolidatedActions
return tool
}
@@ -1859,7 +1863,7 @@ func ActionsRunTrigger(t translations.TranslationHelperFunc) inventory.ServerToo
}
},
)
- tool.FeatureFlagEnable = FeatureFlagConsolidatedActions
+ tool.FeatureFlagDisable = FeatureFlagHoldbackConsolidatedActions
return tool
}
@@ -1977,7 +1981,7 @@ For single job logs, provide job_id. For all failed jobs in a run, provide run_i
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
},
)
- tool.FeatureFlagEnable = FeatureFlagConsolidatedActions
+ tool.FeatureFlagDisable = FeatureFlagHoldbackConsolidatedActions
return tool
}
diff --git a/pkg/github/instructions_test.go b/pkg/github/instructions_test.go
deleted file mode 100644
index b8ad2ba8c..000000000
--- a/pkg/github/instructions_test.go
+++ /dev/null
@@ -1,186 +0,0 @@
-package github
-
-import (
- "os"
- "strings"
- "testing"
-)
-
-func TestGenerateInstructions(t *testing.T) {
- tests := []struct {
- name string
- enabledToolsets []string
- expectedEmpty bool
- }{
- {
- name: "empty toolsets",
- enabledToolsets: []string{},
- expectedEmpty: false,
- },
- {
- name: "only context toolset",
- enabledToolsets: []string{"context"},
- expectedEmpty: false,
- },
- {
- name: "pull requests toolset",
- enabledToolsets: []string{"pull_requests"},
- expectedEmpty: false,
- },
- {
- name: "issues toolset",
- enabledToolsets: []string{"issues"},
- expectedEmpty: false,
- },
- {
- name: "discussions toolset",
- enabledToolsets: []string{"discussions"},
- expectedEmpty: false,
- },
- {
- name: "multiple toolsets (context + pull_requests)",
- enabledToolsets: []string{"context", "pull_requests"},
- expectedEmpty: false,
- },
- {
- name: "multiple toolsets (issues + pull_requests)",
- enabledToolsets: []string{"issues", "pull_requests"},
- expectedEmpty: false,
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- result := GenerateInstructions(tt.enabledToolsets)
-
- if tt.expectedEmpty {
- if result != "" {
- t.Errorf("Expected empty instructions but got: %s", result)
- }
- } else {
- if result == "" {
- t.Errorf("Expected non-empty instructions but got empty result")
- }
- }
- })
- }
-}
-
-func TestGenerateInstructionsWithDisableFlag(t *testing.T) {
- tests := []struct {
- name string
- disableEnvValue string
- enabledToolsets []string
- expectedEmpty bool
- }{
- {
- name: "DISABLE_INSTRUCTIONS=true returns empty",
- disableEnvValue: "true",
- enabledToolsets: []string{"context", "issues", "pull_requests"},
- expectedEmpty: true,
- },
- {
- name: "DISABLE_INSTRUCTIONS=false returns normal instructions",
- disableEnvValue: "false",
- enabledToolsets: []string{"context"},
- expectedEmpty: false,
- },
- {
- name: "DISABLE_INSTRUCTIONS unset returns normal instructions",
- disableEnvValue: "",
- enabledToolsets: []string{"issues"},
- expectedEmpty: false,
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- // Save original env value
- originalValue := os.Getenv("DISABLE_INSTRUCTIONS")
- defer func() {
- if originalValue == "" {
- os.Unsetenv("DISABLE_INSTRUCTIONS")
- } else {
- os.Setenv("DISABLE_INSTRUCTIONS", originalValue)
- }
- }()
-
- // Set test env value
- if tt.disableEnvValue == "" {
- os.Unsetenv("DISABLE_INSTRUCTIONS")
- } else {
- os.Setenv("DISABLE_INSTRUCTIONS", tt.disableEnvValue)
- }
-
- result := GenerateInstructions(tt.enabledToolsets)
-
- if tt.expectedEmpty {
- if result != "" {
- t.Errorf("Expected empty instructions but got: %s", result)
- }
- } else {
- if result == "" {
- t.Errorf("Expected non-empty instructions but got empty result")
- }
- }
- })
- }
-}
-
-func TestGetToolsetInstructions(t *testing.T) {
- tests := []struct {
- toolset string
- expectedEmpty bool
- enabledToolsets []string
- expectedToContain string
- notExpectedToContain string
- }{
- {
- toolset: "pull_requests",
- expectedEmpty: false,
- enabledToolsets: []string{"pull_requests", "repos"},
- expectedToContain: "pull_request_template.md",
- },
- {
- toolset: "pull_requests",
- expectedEmpty: false,
- enabledToolsets: []string{"pull_requests"},
- notExpectedToContain: "pull_request_template.md",
- },
- {
- toolset: "issues",
- expectedEmpty: false,
- },
- {
- toolset: "discussions",
- expectedEmpty: false,
- },
- {
- toolset: "nonexistent",
- expectedEmpty: true,
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.toolset, func(t *testing.T) {
- result := getToolsetInstructions(tt.toolset, tt.enabledToolsets)
- if tt.expectedEmpty {
- if result != "" {
- t.Errorf("Expected empty result for toolset '%s', but got: %s", tt.toolset, result)
- }
- } else {
- if result == "" {
- t.Errorf("Expected non-empty result for toolset '%s', but got empty", tt.toolset)
- }
- }
-
- if tt.expectedToContain != "" && !strings.Contains(result, tt.expectedToContain) {
- t.Errorf("Expected result to contain '%s' for toolset '%s', but it did not. Result: %s", tt.expectedToContain, tt.toolset, result)
- }
-
- if tt.notExpectedToContain != "" && strings.Contains(result, tt.notExpectedToContain) {
- t.Errorf("Did not expect result to contain '%s' for toolset '%s', but it did. Result: %s", tt.notExpectedToContain, tt.toolset, result)
- }
- })
- }
-}
diff --git a/pkg/github/minimal_types.go b/pkg/github/minimal_types.go
index b055efb38..c6a0ea849 100644
--- a/pkg/github/minimal_types.go
+++ b/pkg/github/minimal_types.go
@@ -131,6 +131,7 @@ type MinimalProject struct {
Number *int `json:"number,omitempty"`
ShortDescription *string `json:"short_description,omitempty"`
DeletedBy *MinimalUser `json:"deleted_by,omitempty"`
+ OwnerType string `json:"owner_type,omitempty"`
}
// Helper functions
diff --git a/pkg/github/projects.go b/pkg/github/projects.go
index 8af181a72..a0da0857f 100644
--- a/pkg/github/projects.go
+++ b/pkg/github/projects.go
@@ -16,6 +16,7 @@ import (
"github.com/google/go-github/v79/github"
"github.com/google/jsonschema-go/jsonschema"
"github.com/modelcontextprotocol/go-sdk/mcp"
+ "github.com/shurcooL/githubv4"
)
const (
@@ -26,10 +27,14 @@ const (
MaxProjectsPerPage = 50
)
-// FeatureFlagConsolidatedProjects is the feature flag that disables individual project tools
-// in favor of the consolidated project tools.
+// FeatureFlagConsolidatedProjects is the legacy feature flag (deprecated, no longer used).
+// Kept for documentation purposes only.
const FeatureFlagConsolidatedProjects = "remote_mcp_consolidated_projects"
+// FeatureFlagHoldbackConsolidatedProjects is the feature flag that, when enabled, reverts to
+// individual project tools instead of the consolidated project tools.
+const FeatureFlagHoldbackConsolidatedProjects = "mcp_holdback_consolidated_projects"
+
// Method constants for consolidated project tools
const (
projectsMethodListProjects = "list_projects"
@@ -159,7 +164,7 @@ func ListProjects(t translations.TranslationHelperFunc) inventory.ServerTool {
return utils.NewToolResultText(string(r)), nil, nil
},
)
- tool.FeatureFlagDisable = FeatureFlagConsolidatedProjects
+ tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedProjects
return tool
}
@@ -250,7 +255,7 @@ func GetProject(t translations.TranslationHelperFunc) inventory.ServerTool {
return utils.NewToolResultText(string(r)), nil, nil
},
)
- tool.FeatureFlagDisable = FeatureFlagConsolidatedProjects
+ tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedProjects
return tool
}
@@ -359,7 +364,7 @@ func ListProjectFields(t translations.TranslationHelperFunc) inventory.ServerToo
return utils.NewToolResultText(string(r)), nil, nil
},
)
- tool.FeatureFlagDisable = FeatureFlagConsolidatedProjects
+ tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedProjects
return tool
}
@@ -454,7 +459,7 @@ func GetProjectField(t translations.TranslationHelperFunc) inventory.ServerTool
return utils.NewToolResultText(string(r)), nil, nil
},
)
- tool.FeatureFlagDisable = FeatureFlagConsolidatedProjects
+ tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedProjects
return tool
}
@@ -593,7 +598,7 @@ func ListProjectItems(t translations.TranslationHelperFunc) inventory.ServerTool
return utils.NewToolResultText(string(r)), nil, nil
},
)
- tool.FeatureFlagDisable = FeatureFlagConsolidatedProjects
+ tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedProjects
return tool
}
@@ -702,7 +707,7 @@ func GetProjectItem(t translations.TranslationHelperFunc) inventory.ServerTool {
return utils.NewToolResultText(string(r)), nil, nil
},
)
- tool.FeatureFlagDisable = FeatureFlagConsolidatedProjects
+ tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedProjects
return tool
}
@@ -816,7 +821,7 @@ func AddProjectItem(t translations.TranslationHelperFunc) inventory.ServerTool {
return utils.NewToolResultText(string(r)), nil, nil
},
)
- tool.FeatureFlagDisable = FeatureFlagConsolidatedProjects
+ tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedProjects
return tool
}
@@ -931,7 +936,7 @@ func UpdateProjectItem(t translations.TranslationHelperFunc) inventory.ServerToo
return utils.NewToolResultText(string(r)), nil, nil
},
)
- tool.FeatureFlagDisable = FeatureFlagConsolidatedProjects
+ tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedProjects
return tool
}
@@ -1020,7 +1025,7 @@ func DeleteProjectItem(t translations.TranslationHelperFunc) inventory.ServerToo
return utils.NewToolResultText("project item successfully deleted"), nil, nil
},
)
- tool.FeatureFlagDisable = FeatureFlagConsolidatedProjects
+ tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedProjects
return tool
}
@@ -1052,12 +1057,12 @@ Use this tool to list projects for a user or organization, or list project field
},
"owner_type": {
Type: "string",
- Description: "Owner type",
+ Description: "Owner type (user or org). If not provided, will automatically try both.",
Enum: []any{"user", "org"},
},
"owner": {
Type: "string",
- Description: "If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive.",
+ Description: "The owner (user or organization login). The name is not case sensitive.",
},
"project_number": {
Type: "number",
@@ -1087,7 +1092,7 @@ Use this tool to list projects for a user or organization, or list project field
Description: "Backward pagination cursor from previous pageInfo.prevCursor (rare).",
},
},
- Required: []string{"method", "owner_type", "owner"},
+ Required: []string{"method", "owner"},
},
},
[]scopes.Scope{scopes.ReadProject},
@@ -1102,7 +1107,7 @@ Use this tool to list projects for a user or organization, or list project field
return utils.NewToolResultError(err.Error()), nil, nil
}
- ownerType, err := RequiredParam[string](args, "owner_type")
+ ownerType, err := OptionalParam[string](args, "owner_type")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
@@ -1116,15 +1121,37 @@ Use this tool to list projects for a user or organization, or list project field
case projectsMethodListProjects:
return listProjects(ctx, client, args, owner, ownerType)
case projectsMethodListProjectFields:
+ // Detect owner type if not provided and project_number is available
+ if ownerType == "" {
+ projectNumber, err := RequiredInt(args, "project_number")
+ if err != nil {
+ return utils.NewToolResultError(err.Error()), nil, nil
+ }
+ ownerType, err = detectOwnerType(ctx, client, owner, projectNumber)
+ if err != nil {
+ return utils.NewToolResultError(err.Error()), nil, nil
+ }
+ }
return listProjectFields(ctx, client, args, owner, ownerType)
case projectsMethodListProjectItems:
+ // Detect owner type if not provided and project_number is available
+ if ownerType == "" {
+ projectNumber, err := RequiredInt(args, "project_number")
+ if err != nil {
+ return utils.NewToolResultError(err.Error()), nil, nil
+ }
+ ownerType, err = detectOwnerType(ctx, client, owner, projectNumber)
+ if err != nil {
+ return utils.NewToolResultError(err.Error()), nil, nil
+ }
+ }
return listProjectItems(ctx, client, args, owner, ownerType)
default:
return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil
}
},
)
- tool.FeatureFlagEnable = FeatureFlagConsolidatedProjects
+ tool.FeatureFlagDisable = FeatureFlagHoldbackConsolidatedProjects
return tool
}
@@ -1155,12 +1182,12 @@ Use this tool to get details about individual projects, project fields, and proj
},
"owner_type": {
Type: "string",
- Description: "Owner type",
+ Description: "Owner type (user or org). If not provided, will be automatically detected.",
Enum: []any{"user", "org"},
},
"owner": {
Type: "string",
- Description: "If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive.",
+ Description: "The owner (user or organization login). The name is not case sensitive.",
},
"project_number": {
Type: "number",
@@ -1182,7 +1209,7 @@ Use this tool to get details about individual projects, project fields, and proj
},
},
},
- Required: []string{"method", "owner_type", "owner", "project_number"},
+ Required: []string{"method", "owner", "project_number"},
},
},
[]scopes.Scope{scopes.ReadProject},
@@ -1197,7 +1224,7 @@ Use this tool to get details about individual projects, project fields, and proj
return utils.NewToolResultError(err.Error()), nil, nil
}
- ownerType, err := RequiredParam[string](args, "owner_type")
+ ownerType, err := OptionalParam[string](args, "owner_type")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
@@ -1212,6 +1239,14 @@ Use this tool to get details about individual projects, project fields, and proj
return utils.NewToolResultError(err.Error()), nil, nil
}
+ // Detect owner type if not provided
+ if ownerType == "" {
+ ownerType, err = detectOwnerType(ctx, client, owner, projectNumber)
+ if err != nil {
+ return utils.NewToolResultError(err.Error()), nil, nil
+ }
+ }
+
switch method {
case projectsMethodGetProject:
return getProject(ctx, client, owner, ownerType, projectNumber)
@@ -1236,7 +1271,7 @@ Use this tool to get details about individual projects, project fields, and proj
}
},
)
- tool.FeatureFlagEnable = FeatureFlagConsolidatedProjects
+ tool.FeatureFlagDisable = FeatureFlagHoldbackConsolidatedProjects
return tool
}
@@ -1266,12 +1301,12 @@ func ProjectsWrite(t translations.TranslationHelperFunc) inventory.ServerTool {
},
"owner_type": {
Type: "string",
- Description: "Owner type",
+ Description: "Owner type (user or org). If not provided, will be automatically detected.",
Enum: []any{"user", "org"},
},
"owner": {
Type: "string",
- Description: "If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive.",
+ Description: "The project owner (user or organization login). The name is not case sensitive.",
},
"project_number": {
Type: "number",
@@ -1279,19 +1314,35 @@ func ProjectsWrite(t translations.TranslationHelperFunc) inventory.ServerTool {
},
"item_id": {
Type: "number",
- Description: "The project item ID. Required for 'update_project_item' and 'delete_project_item' methods. For add_project_item, this is the numeric ID of the issue or pull request to add.",
+ Description: "The project item ID. Required for 'update_project_item' and 'delete_project_item' methods.",
},
"item_type": {
Type: "string",
Description: "The item's type, either issue or pull_request. Required for 'add_project_item' method.",
Enum: []any{"issue", "pull_request"},
},
+ "item_owner": {
+ Type: "string",
+ Description: "The owner (user or organization) of the repository containing the issue or pull request. Required for 'add_project_item' method.",
+ },
+ "item_repo": {
+ Type: "string",
+ Description: "The name of the repository containing the issue or pull request. Required for 'add_project_item' method.",
+ },
+ "issue_number": {
+ Type: "number",
+ Description: "The issue number (use when item_type is 'issue' for 'add_project_item' method). Provide either issue_number or pull_request_number.",
+ },
+ "pull_request_number": {
+ Type: "number",
+ Description: "The pull request number (use when item_type is 'pull_request' for 'add_project_item' method). Provide either issue_number or pull_request_number.",
+ },
"updated_field": {
Type: "object",
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.",
},
},
- Required: []string{"method", "owner_type", "owner", "project_number"},
+ Required: []string{"method", "owner", "project_number"},
},
},
[]scopes.Scope{scopes.Project},
@@ -1306,7 +1357,7 @@ func ProjectsWrite(t translations.TranslationHelperFunc) inventory.ServerTool {
return utils.NewToolResultError(err.Error()), nil, nil
}
- ownerType, err := RequiredParam[string](args, "owner_type")
+ ownerType, err := OptionalParam[string](args, "owner_type")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
@@ -1321,17 +1372,51 @@ func ProjectsWrite(t translations.TranslationHelperFunc) inventory.ServerTool {
return utils.NewToolResultError(err.Error()), nil, nil
}
+ gqlClient, err := deps.GetGQLClient(ctx)
+ if err != nil {
+ return utils.NewToolResultError(err.Error()), nil, nil
+ }
+
+ // Detect owner type if not provided
+ if ownerType == "" {
+ ownerType, err = detectOwnerType(ctx, client, owner, projectNumber)
+ if err != nil {
+ return utils.NewToolResultError(err.Error()), nil, nil
+ }
+ }
+
switch method {
case projectsMethodAddProjectItem:
- itemID, err := RequiredBigInt(args, "item_id")
+ itemType, err := RequiredParam[string](args, "item_type")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
- itemType, err := RequiredParam[string](args, "item_type")
+ itemOwner, err := RequiredParam[string](args, "item_owner")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
- return addProjectItem(ctx, client, owner, ownerType, projectNumber, itemID, itemType)
+ itemRepo, err := RequiredParam[string](args, "item_repo")
+ if err != nil {
+ return utils.NewToolResultError(err.Error()), nil, nil
+ }
+
+ var itemNumber int
+ switch itemType {
+ case "issue":
+ itemNumber, err = RequiredInt(args, "issue_number")
+ if err != nil {
+ return utils.NewToolResultError("issue_number is required when item_type is 'issue'"), nil, nil
+ }
+ case "pull_request":
+ itemNumber, err = RequiredInt(args, "pull_request_number")
+ if err != nil {
+ return utils.NewToolResultError("pull_request_number is required when item_type is 'pull_request'"), nil, nil
+ }
+ default:
+ return utils.NewToolResultError("item_type must be either 'issue' or 'pull_request'"), nil, nil
+ }
+
+ return addProjectItem(ctx, gqlClient, owner, ownerType, projectNumber, itemOwner, itemRepo, itemNumber, itemType)
case projectsMethodUpdateProjectItem:
itemID, err := RequiredBigInt(args, "item_id")
if err != nil {
@@ -1357,7 +1442,7 @@ func ProjectsWrite(t translations.TranslationHelperFunc) inventory.ServerTool {
}
},
)
- tool.FeatureFlagEnable = FeatureFlagConsolidatedProjects
+ tool.FeatureFlagDisable = FeatureFlagHoldbackConsolidatedProjects
return tool
}
@@ -1388,35 +1473,105 @@ func listProjects(ctx context.Context, client *github.Client, args map[string]an
Query: queryPtr,
}
- if ownerType == "org" {
+ // If owner_type not provided, fetch from both user and org
+ switch ownerType {
+ case "":
+ return listProjectsFromBothOwnerTypes(ctx, client, owner, opts)
+ case "org":
projects, resp, err = client.Projects.ListOrganizationProjects(ctx, owner, opts)
- } else {
+ if err != nil {
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ "failed to list projects",
+ resp,
+ err,
+ ), nil, nil
+ }
+ default:
projects, resp, err = client.Projects.ListUserProjects(ctx, owner, opts)
+ if err != nil {
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ "failed to list projects",
+ resp,
+ err,
+ ), nil, nil
+ }
}
- if err != nil {
- return ghErrors.NewGitHubAPIErrorResponse(ctx,
- "failed to list projects",
- resp,
- err,
- ), nil, nil
+ // For specified owner_type, process normally
+ if ownerType != "" {
+ defer func() { _ = resp.Body.Close() }()
+
+ for _, project := range projects {
+ mp := convertToMinimalProject(project)
+ mp.OwnerType = ownerType
+ minimalProjects = append(minimalProjects, *mp)
+ }
+
+ response := map[string]any{
+ "projects": minimalProjects,
+ "pageInfo": buildPageInfo(resp),
+ }
+
+ r, err := json.Marshal(response)
+ if err != nil {
+ return nil, nil, fmt.Errorf("failed to marshal response: %w", err)
+ }
+
+ return utils.NewToolResultText(string(r)), nil, nil
}
- defer func() { _ = resp.Body.Close() }()
- for _, project := range projects {
- minimalProjects = append(minimalProjects, *convertToMinimalProject(project))
+ return nil, nil, fmt.Errorf("unexpected state in listProjects")
+}
+
+// listProjectsFromBothOwnerTypes fetches projects from both user and org endpoints
+// when owner_type is not specified, combining the results with owner_type labels.
+func listProjectsFromBothOwnerTypes(ctx context.Context, client *github.Client, owner string, opts *github.ListProjectsOptions) (*mcp.CallToolResult, any, error) {
+ var minimalProjects []MinimalProject
+ var resp *github.Response
+
+ // Fetch user projects
+ userProjects, userResp, userErr := client.Projects.ListUserProjects(ctx, owner, opts)
+ if userErr == nil && userResp.StatusCode == http.StatusOK {
+ for _, project := range userProjects {
+ mp := convertToMinimalProject(project)
+ mp.OwnerType = "user"
+ minimalProjects = append(minimalProjects, *mp)
+ }
+ _ = userResp.Body.Close()
+ }
+
+ // Fetch org projects
+ orgProjects, orgResp, orgErr := client.Projects.ListOrganizationProjects(ctx, owner, opts)
+ if orgErr == nil && orgResp.StatusCode == http.StatusOK {
+ for _, project := range orgProjects {
+ mp := convertToMinimalProject(project)
+ mp.OwnerType = "org"
+ minimalProjects = append(minimalProjects, *mp)
+ }
+ resp = orgResp // Use org response for pagination info
+ } else if userResp != nil {
+ resp = userResp // Fallback to user response
+ }
+
+ // If both failed, return error
+ if (userErr != nil || userResp == nil || userResp.StatusCode != http.StatusOK) &&
+ (orgErr != nil || orgResp == nil || orgResp.StatusCode != http.StatusOK) {
+ return utils.NewToolResultError(fmt.Sprintf("failed to list projects for owner '%s': not found as user or organization", owner)), nil, nil
}
response := map[string]any{
"projects": minimalProjects,
- "pageInfo": buildPageInfo(resp),
+ "note": "Results include both user and org projects. Each project includes 'owner_type' field. Pagination is limited when owner_type is not specified - specify 'owner_type' for full pagination support.",
+ }
+ if resp != nil {
+ response["pageInfo"] = buildPageInfo(resp)
+ defer func() { _ = resp.Body.Close() }()
}
r, err := json.Marshal(response)
if err != nil {
return nil, nil, fmt.Errorf("failed to marshal response: %w", err)
}
-
return utils.NewToolResultText(string(r)), nil, nil
}
@@ -1645,50 +1800,6 @@ func getProjectItem(ctx context.Context, client *github.Client, owner, ownerType
return utils.NewToolResultText(string(r)), nil, nil
}
-func addProjectItem(ctx context.Context, client *github.Client, owner, ownerType string, projectNumber int, itemID int64, itemType string) (*mcp.CallToolResult, any, error) {
- if itemType != "issue" && itemType != "pull_request" {
- return utils.NewToolResultError("item_type must be either 'issue' or 'pull_request'"), nil, nil
- }
-
- newItem := &github.AddProjectItemOptions{
- ID: itemID,
- Type: toNewProjectType(itemType),
- }
-
- var resp *github.Response
- var addedItem *github.ProjectV2Item
- var err error
-
- if ownerType == "org" {
- addedItem, resp, err = client.Projects.AddOrganizationProjectItem(ctx, owner, projectNumber, newItem)
- } else {
- addedItem, resp, err = client.Projects.AddUserProjectItem(ctx, owner, projectNumber, newItem)
- }
-
- if err != nil {
- return ghErrors.NewGitHubAPIErrorResponse(ctx,
- ProjectAddFailedError,
- resp,
- err,
- ), nil, nil
- }
- defer func() { _ = resp.Body.Close() }()
-
- if resp.StatusCode != http.StatusCreated {
- body, err := io.ReadAll(resp.Body)
- if err != nil {
- return nil, nil, fmt.Errorf("failed to read response body: %w", err)
- }
- return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, ProjectAddFailedError, resp, body), nil, nil
- }
- r, err := json.Marshal(addedItem)
- if err != nil {
- return nil, nil, fmt.Errorf("failed to marshal response: %w", err)
- }
-
- return utils.NewToolResultText(string(r)), nil, nil
-}
-
func updateProjectItem(ctx context.Context, client *github.Client, owner, ownerType string, projectNumber int, itemID int64, fieldValue map[string]any) (*mcp.CallToolResult, any, error) {
updatePayload, err := buildUpdateProjectItem(fieldValue)
if err != nil {
@@ -1757,6 +1868,94 @@ func deleteProjectItem(ctx context.Context, client *github.Client, owner, ownerT
return utils.NewToolResultText("project item successfully deleted"), nil, nil
}
+// addProjectItem adds an item to a project by resolving the issue/PR number to a node ID
+func addProjectItem(ctx context.Context, gqlClient *githubv4.Client, owner, ownerType string, projectNumber int, itemOwner, itemRepo string, itemNumber int, itemType string) (*mcp.CallToolResult, any, error) {
+ if itemType != "issue" && itemType != "pull_request" {
+ return utils.NewToolResultError("item_type must be either 'issue' or 'pull_request'"), nil, nil
+ }
+
+ // Resolve the item number to a node ID
+ var nodeID githubv4.ID
+ var err error
+ if itemType == "issue" {
+ nodeID, err = resolveIssueNodeID(ctx, gqlClient, itemOwner, itemRepo, itemNumber)
+ } else {
+ nodeID, err = resolvePullRequestNodeID(ctx, gqlClient, itemOwner, itemRepo, itemNumber)
+ }
+ if err != nil {
+ return utils.NewToolResultError(fmt.Sprintf("failed to resolve %s: %v", itemType, err)), nil, nil
+ }
+
+ // Use GraphQL to add the item to the project
+ var mutation struct {
+ AddProjectV2ItemByID struct {
+ Item struct {
+ ID githubv4.ID
+ }
+ } `graphql:"addProjectV2ItemById(input: $input)"`
+ }
+
+ // First, get the project ID
+ var projectIDQuery struct {
+ User struct {
+ ProjectV2 struct {
+ ID githubv4.ID
+ } `graphql:"projectV2(number: $projectNumber)"`
+ } `graphql:"user(login: $owner)"`
+ }
+ var projectIDQueryOrg struct {
+ Organization struct {
+ ProjectV2 struct {
+ ID githubv4.ID
+ } `graphql:"projectV2(number: $projectNumber)"`
+ } `graphql:"organization(login: $owner)"`
+ }
+
+ var projectID githubv4.ID
+ if ownerType == "org" {
+ err = gqlClient.Query(ctx, &projectIDQueryOrg, map[string]any{
+ "owner": githubv4.String(owner),
+ "projectNumber": githubv4.Int(int32(projectNumber)), //nolint:gosec // Project numbers are small integers
+ })
+ if err != nil {
+ return utils.NewToolResultError(fmt.Sprintf("failed to get project ID: %v", err)), nil, nil
+ }
+ projectID = projectIDQueryOrg.Organization.ProjectV2.ID
+ } else {
+ err = gqlClient.Query(ctx, &projectIDQuery, map[string]any{
+ "owner": githubv4.String(owner),
+ "projectNumber": githubv4.Int(int32(projectNumber)), //nolint:gosec // Project numbers are small integers
+ })
+ if err != nil {
+ return utils.NewToolResultError(fmt.Sprintf("failed to get project ID: %v", err)), nil, nil
+ }
+ projectID = projectIDQuery.User.ProjectV2.ID
+ }
+
+ // Add the item to the project
+ input := githubv4.AddProjectV2ItemByIdInput{
+ ProjectID: projectID,
+ ContentID: nodeID,
+ }
+
+ err = gqlClient.Mutate(ctx, &mutation, input, nil)
+ if err != nil {
+ return utils.NewToolResultError(fmt.Sprintf(ProjectAddFailedError+": %v", err)), nil, nil
+ }
+
+ result := map[string]any{
+ "id": mutation.AddProjectV2ItemByID.Item.ID,
+ "message": fmt.Sprintf("Successfully added %s %s/%s#%d to project %s/%d", itemType, itemOwner, itemRepo, itemNumber, owner, projectNumber),
+ }
+
+ r, err := json.Marshal(result)
+ if err != nil {
+ return nil, nil, fmt.Errorf("failed to marshal response: %w", err)
+ }
+
+ return utils.NewToolResultText(string(r)), nil, nil
+}
+
type pageInfo struct {
HasNextPage bool `json:"hasNextPage"`
HasPreviousPage bool `json:"hasPreviousPage"`
@@ -1868,3 +2067,77 @@ func extractPaginationOptionsFromArgs(args map[string]any) (github.ListProjectsP
return opts, nil
}
+
+// resolveIssueNodeID resolves an issue number to its GraphQL node ID
+func resolveIssueNodeID(ctx context.Context, gqlClient *githubv4.Client, owner, repo string, issueNumber int) (githubv4.ID, error) {
+ var query struct {
+ Repository struct {
+ Issue struct {
+ ID githubv4.ID
+ } `graphql:"issue(number: $issueNumber)"`
+ } `graphql:"repository(owner: $owner, name: $repo)"`
+ }
+
+ variables := map[string]any{
+ "owner": githubv4.String(owner),
+ "repo": githubv4.String(repo),
+ "issueNumber": githubv4.Int(int32(issueNumber)), //nolint:gosec // Issue numbers are small integers
+ }
+
+ err := gqlClient.Query(ctx, &query, variables)
+ if err != nil {
+ return "", fmt.Errorf("failed to resolve issue %s/%s#%d: %w", owner, repo, issueNumber, err)
+ }
+
+ return query.Repository.Issue.ID, nil
+}
+
+// resolvePullRequestNodeID resolves a pull request number to its GraphQL node ID
+func resolvePullRequestNodeID(ctx context.Context, gqlClient *githubv4.Client, owner, repo string, prNumber int) (githubv4.ID, error) {
+ var query struct {
+ Repository struct {
+ PullRequest struct {
+ ID githubv4.ID
+ } `graphql:"pullRequest(number: $prNumber)"`
+ } `graphql:"repository(owner: $owner, name: $repo)"`
+ }
+
+ variables := map[string]any{
+ "owner": githubv4.String(owner),
+ "repo": githubv4.String(repo),
+ "prNumber": githubv4.Int(int32(prNumber)), //nolint:gosec // PR numbers are small integers
+ }
+
+ err := gqlClient.Query(ctx, &query, variables)
+ if err != nil {
+ return "", fmt.Errorf("failed to resolve pull request %s/%s#%d: %w", owner, repo, prNumber, err)
+ }
+
+ return query.Repository.PullRequest.ID, nil
+}
+
+// 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) {
+ // Try user first (more common for personal projects)
+ _, resp, err := client.Projects.GetUserProject(ctx, owner, projectNumber)
+ if err == nil && resp.StatusCode == http.StatusOK {
+ _ = resp.Body.Close()
+ return "user", nil
+ }
+ if resp != nil {
+ _ = resp.Body.Close()
+ }
+
+ // If not found (404) or other error, try org
+ _, resp, err = client.Projects.GetOrganizationProject(ctx, owner, projectNumber)
+ if err == nil && resp.StatusCode == http.StatusOK {
+ _ = resp.Body.Close()
+ return "org", nil
+ }
+ if resp != nil {
+ _ = resp.Body.Close()
+ }
+
+ return "", fmt.Errorf("could not determine owner type for %s with project %d: owner is neither a user nor an org with this project", owner, projectNumber)
+}
diff --git a/pkg/github/projects_test.go b/pkg/github/projects_test.go
index 9819e7d7e..24163ef90 100644
--- a/pkg/github/projects_test.go
+++ b/pkg/github/projects_test.go
@@ -6,10 +6,12 @@ import (
"net/http"
"testing"
+ "github.com/github/github-mcp-server/internal/githubv4mock"
"github.com/github/github-mcp-server/internal/toolsnaps"
"github.com/github/github-mcp-server/pkg/translations"
gh "github.com/google/go-github/v79/github"
"github.com/google/jsonschema-go/jsonschema"
+ "github.com/shurcooL/githubv4"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -1546,7 +1548,7 @@ func Test_ProjectsList(t *testing.T) {
assert.Contains(t, inputSchema.Properties, "project_number")
assert.Contains(t, inputSchema.Properties, "query")
assert.Contains(t, inputSchema.Properties, "fields")
- assert.ElementsMatch(t, inputSchema.Required, []string{"method", "owner_type", "owner"})
+ assert.ElementsMatch(t, inputSchema.Required, []string{"method", "owner"})
}
func Test_ProjectsList_ListProjects(t *testing.T) {
@@ -1750,7 +1752,7 @@ func Test_ProjectsGet(t *testing.T) {
assert.Contains(t, inputSchema.Properties, "project_number")
assert.Contains(t, inputSchema.Properties, "field_id")
assert.Contains(t, inputSchema.Properties, "item_id")
- assert.ElementsMatch(t, inputSchema.Required, []string{"method", "owner_type", "owner", "project_number"})
+ assert.ElementsMatch(t, inputSchema.Required, []string{"method", "owner", "project_number"})
}
func Test_ProjectsGet_GetProject(t *testing.T) {
@@ -1934,8 +1936,12 @@ func Test_ProjectsWrite(t *testing.T) {
assert.Contains(t, inputSchema.Properties, "project_number")
assert.Contains(t, inputSchema.Properties, "item_id")
assert.Contains(t, inputSchema.Properties, "item_type")
+ assert.Contains(t, inputSchema.Properties, "item_owner")
+ assert.Contains(t, inputSchema.Properties, "item_repo")
+ 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_type", "owner", "project_number"})
+ assert.ElementsMatch(t, inputSchema.Required, []string{"method", "owner", "project_number"})
// Verify DestructiveHint is set
assert.NotNil(t, toolDef.Tool.Annotations)
@@ -1946,19 +1952,78 @@ func Test_ProjectsWrite(t *testing.T) {
func Test_ProjectsWrite_AddProjectItem(t *testing.T) {
toolDef := ProjectsWrite(translations.NullTranslationHelper)
- addedItem := map[string]any{"id": 2001, "archived_at": nil}
-
- t.Run("success organization", func(t *testing.T) {
- mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
- PostOrgsProjectsV2ItemsByProject: expectRequestBody(t, map[string]any{
- "type": "Issue",
- "id": float64(123),
- }).andThen(mockResponse(t, http.StatusCreated, addedItem)),
- })
+ t.Run("success organization with issue", func(t *testing.T) {
+ mockedClient := githubv4mock.NewMockedHTTPClient(
+ // Mock resolveIssueNodeID query
+ 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("item-owner"),
+ "repo": githubv4.String("item-repo"),
+ "issueNumber": githubv4.Int(123),
+ },
+ githubv4mock.DataResponse(map[string]any{
+ "repository": map[string]any{
+ "issue": map[string]any{
+ "id": "I_issue123",
+ },
+ },
+ }),
+ ),
+ // Mock project ID query for org
+ githubv4mock.NewQueryMatcher(
+ struct {
+ Organization struct {
+ ProjectV2 struct {
+ ID githubv4.ID
+ } `graphql:"projectV2(number: $projectNumber)"`
+ } `graphql:"organization(login: $owner)"`
+ }{},
+ map[string]any{
+ "owner": githubv4.String("octo-org"),
+ "projectNumber": githubv4.Int(1),
+ },
+ githubv4mock.DataResponse(map[string]any{
+ "organization": map[string]any{
+ "projectV2": map[string]any{
+ "id": "PVT_project1",
+ },
+ },
+ }),
+ ),
+ // Mock addProjectV2ItemById mutation
+ githubv4mock.NewMutationMatcher(
+ struct {
+ AddProjectV2ItemByID struct {
+ Item struct {
+ ID githubv4.ID
+ }
+ } `graphql:"addProjectV2ItemById(input: $input)"`
+ }{},
+ githubv4.AddProjectV2ItemByIdInput{
+ ProjectID: githubv4.ID("PVT_project1"),
+ ContentID: githubv4.ID("I_issue123"),
+ },
+ nil,
+ githubv4mock.DataResponse(map[string]any{
+ "addProjectV2ItemById": map[string]any{
+ "item": map[string]any{
+ "id": "PVTI_item1",
+ },
+ },
+ }),
+ ),
+ )
- client := gh.NewClient(mockedClient)
+ client := githubv4.NewClient(mockedClient)
deps := BaseDeps{
- Client: client,
+ GQLClient: client,
}
handler := toolDef.Handler(deps)
request := createMCPRequest(map[string]any{
@@ -1966,7 +2031,9 @@ func Test_ProjectsWrite_AddProjectItem(t *testing.T) {
"owner": "octo-org",
"owner_type": "org",
"project_number": float64(1),
- "item_id": float64(123),
+ "item_owner": "item-owner",
+ "item_repo": "item-repo",
+ "issue_number": float64(123),
"item_type": "issue",
})
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
@@ -1979,13 +2046,111 @@ func Test_ProjectsWrite_AddProjectItem(t *testing.T) {
err = json.Unmarshal([]byte(textContent.Text), &response)
require.NoError(t, err)
assert.NotNil(t, response["id"])
+ assert.Contains(t, response["message"], "Successfully added")
+ })
+
+ t.Run("success user with pull request", func(t *testing.T) {
+ mockedClient := githubv4mock.NewMockedHTTPClient(
+ // Mock resolvePullRequestNodeID query
+ githubv4mock.NewQueryMatcher(
+ struct {
+ Repository struct {
+ PullRequest struct {
+ ID githubv4.ID
+ } `graphql:"pullRequest(number: $prNumber)"`
+ } `graphql:"repository(owner: $owner, name: $repo)"`
+ }{},
+ map[string]any{
+ "owner": githubv4.String("item-owner"),
+ "repo": githubv4.String("item-repo"),
+ "prNumber": githubv4.Int(456),
+ },
+ githubv4mock.DataResponse(map[string]any{
+ "repository": map[string]any{
+ "pullRequest": map[string]any{
+ "id": "PR_pr456",
+ },
+ },
+ }),
+ ),
+ // Mock project ID query for user
+ githubv4mock.NewQueryMatcher(
+ struct {
+ User struct {
+ ProjectV2 struct {
+ ID githubv4.ID
+ } `graphql:"projectV2(number: $projectNumber)"`
+ } `graphql:"user(login: $owner)"`
+ }{},
+ map[string]any{
+ "owner": githubv4.String("octo-user"),
+ "projectNumber": githubv4.Int(2),
+ },
+ githubv4mock.DataResponse(map[string]any{
+ "user": map[string]any{
+ "projectV2": map[string]any{
+ "id": "PVT_project2",
+ },
+ },
+ }),
+ ),
+ // Mock addProjectV2ItemById mutation
+ githubv4mock.NewMutationMatcher(
+ struct {
+ AddProjectV2ItemByID struct {
+ Item struct {
+ ID githubv4.ID
+ }
+ } `graphql:"addProjectV2ItemById(input: $input)"`
+ }{},
+ githubv4.AddProjectV2ItemByIdInput{
+ ProjectID: githubv4.ID("PVT_project2"),
+ ContentID: githubv4.ID("PR_pr456"),
+ },
+ nil,
+ githubv4mock.DataResponse(map[string]any{
+ "addProjectV2ItemById": map[string]any{
+ "item": map[string]any{
+ "id": "PVTI_item2",
+ },
+ },
+ }),
+ ),
+ )
+
+ client := githubv4.NewClient(mockedClient)
+ deps := BaseDeps{
+ GQLClient: client,
+ }
+ handler := toolDef.Handler(deps)
+ request := createMCPRequest(map[string]any{
+ "method": "add_project_item",
+ "owner": "octo-user",
+ "owner_type": "user",
+ "project_number": float64(2),
+ "item_owner": "item-owner",
+ "item_repo": "item-repo",
+ "pull_request_number": float64(456),
+ "item_type": "pull_request",
+ })
+ 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.NotNil(t, response["id"])
+ assert.Contains(t, response["message"], "Successfully added")
})
t.Run("missing item_type", func(t *testing.T) {
- mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{})
- client := gh.NewClient(mockedClient)
+ mockedClient := githubv4mock.NewMockedHTTPClient()
+ client := githubv4.NewClient(mockedClient)
deps := BaseDeps{
- Client: client,
+ GQLClient: client,
}
handler := toolDef.Handler(deps)
request := createMCPRequest(map[string]any{
@@ -1993,7 +2158,9 @@ func Test_ProjectsWrite_AddProjectItem(t *testing.T) {
"owner": "octo-org",
"owner_type": "org",
"project_number": float64(1),
- "item_id": float64(123),
+ "item_owner": "item-owner",
+ "item_repo": "item-repo",
+ "issue_number": float64(123),
})
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
@@ -2004,10 +2171,10 @@ func Test_ProjectsWrite_AddProjectItem(t *testing.T) {
})
t.Run("invalid item_type", func(t *testing.T) {
- mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{})
- client := gh.NewClient(mockedClient)
+ mockedClient := githubv4mock.NewMockedHTTPClient()
+ client := githubv4.NewClient(mockedClient)
deps := BaseDeps{
- Client: client,
+ GQLClient: client,
}
handler := toolDef.Handler(deps)
request := createMCPRequest(map[string]any{
@@ -2015,7 +2182,9 @@ func Test_ProjectsWrite_AddProjectItem(t *testing.T) {
"owner": "octo-org",
"owner_type": "org",
"project_number": float64(1),
- "item_id": float64(123),
+ "item_owner": "item-owner",
+ "item_repo": "item-repo",
+ "issue_number": float64(123),
"item_type": "invalid_type",
})
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
@@ -2027,10 +2196,10 @@ func Test_ProjectsWrite_AddProjectItem(t *testing.T) {
})
t.Run("unknown method", func(t *testing.T) {
- mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{})
- client := gh.NewClient(mockedClient)
+ mockedClient := githubv4mock.NewMockedHTTPClient()
+ client := githubv4.NewClient(mockedClient)
deps := BaseDeps{
- Client: client,
+ GQLClient: client,
}
handler := toolDef.Handler(deps)
request := createMCPRequest(map[string]any{
diff --git a/pkg/github/tools.go b/pkg/github/tools.go
index 4384b730d..a169ff591 100644
--- a/pkg/github/tools.go
+++ b/pkg/github/tools.go
@@ -28,10 +28,11 @@ var (
Icon: "check-circle",
}
ToolsetMetadataContext = inventory.ToolsetMetadata{
- ID: "context",
- Description: "Tools that provide context about the current user and GitHub context you are operating in",
- Default: true,
- Icon: "person",
+ ID: "context",
+ Description: "Tools that provide context about the current user and GitHub context you are operating in",
+ Default: true,
+ Icon: "person",
+ InstructionsFunc: generateContextToolsetInstructions,
}
ToolsetMetadataRepos = inventory.ToolsetMetadata{
ID: "repos",
@@ -45,16 +46,18 @@ var (
Icon: "git-branch",
}
ToolsetMetadataIssues = inventory.ToolsetMetadata{
- ID: "issues",
- Description: "GitHub Issues related tools",
- Default: true,
- Icon: "issue-opened",
+ ID: "issues",
+ Description: "GitHub Issues related tools",
+ Default: true,
+ Icon: "issue-opened",
+ InstructionsFunc: generateIssuesToolsetInstructions,
}
ToolsetMetadataPullRequests = inventory.ToolsetMetadata{
- ID: "pull_requests",
- Description: "GitHub Pull Request related tools",
- Default: true,
- Icon: "git-pull-request",
+ ID: "pull_requests",
+ Description: "GitHub Pull Request related tools",
+ Default: true,
+ Icon: "git-pull-request",
+ InstructionsFunc: generatePullRequestsToolsetInstructions,
}
ToolsetMetadataUsers = inventory.ToolsetMetadata{
ID: "users",
@@ -93,9 +96,10 @@ var (
Icon: "bell",
}
ToolsetMetadataDiscussions = inventory.ToolsetMetadata{
- ID: "discussions",
- Description: "GitHub Discussions related tools",
- Icon: "comment-discussion",
+ ID: "discussions",
+ Description: "GitHub Discussions related tools",
+ Icon: "comment-discussion",
+ InstructionsFunc: generateDiscussionsToolsetInstructions,
}
ToolsetMetadataGists = inventory.ToolsetMetadata{
ID: "gists",
@@ -108,9 +112,10 @@ var (
Icon: "shield",
}
ToolsetMetadataProjects = inventory.ToolsetMetadata{
- ID: "projects",
- Description: "GitHub Projects related tools",
- Icon: "project",
+ ID: "projects",
+ Description: "GitHub Projects related tools",
+ Icon: "project",
+ InstructionsFunc: generateProjectsToolsetInstructions,
}
ToolsetMetadataStargazers = inventory.ToolsetMetadata{
ID: "stargazers",
diff --git a/pkg/github/instructions.go b/pkg/github/toolset_instructions.go
similarity index 65%
rename from pkg/github/instructions.go
rename to pkg/github/toolset_instructions.go
index 3a5fb54bb..bf2388a3d 100644
--- a/pkg/github/instructions.go
+++ b/pkg/github/toolset_instructions.go
@@ -1,75 +1,41 @@
package github
-import (
- "os"
- "slices"
- "strings"
-)
-
-// GenerateInstructions creates server instructions based on enabled toolsets
-func GenerateInstructions(enabledToolsets []string) string {
- // For testing - add a flag to disable instructions
- if os.Getenv("DISABLE_INSTRUCTIONS") == "true" {
- return "" // Baseline mode
- }
-
- var instructions []string
+import "github.com/github/github-mcp-server/pkg/inventory"
- // Core instruction - always included if context toolset enabled
- if slices.Contains(enabledToolsets, "context") {
- instructions = append(instructions, "Always call 'get_me' first to understand current user permissions and context.")
- }
-
- // Individual toolset instructions
- for _, toolset := range enabledToolsets {
- if inst := getToolsetInstructions(toolset, enabledToolsets); inst != "" {
- instructions = append(instructions, inst)
- }
- }
+// Toolset instruction functions - these generate context-aware instructions for each toolset.
+// They are called during inventory build to generate server instructions.
- // Base instruction with context management
- baseInstruction := `The GitHub MCP Server provides tools to interact with GitHub platform.
-
-Tool selection guidance:
- 1. Use 'list_*' tools for broad, simple retrieval and pagination of all items of a type (e.g., all issues, all PRs, all branches) with basic filtering.
- 2. Use 'search_*' tools for targeted queries with specific criteria, keywords, or complex filters (e.g., issues with certain text, PRs by author, code containing functions).
-
-Context management:
- 1. Use pagination whenever possible with batches of 5-10 items.
- 2. Use minimal_output parameter set to true if the full information is not needed to accomplish a task.
-
-Tool usage guidance:
- 1. For 'search_*' tools: Use separate 'sort' and 'order' parameters if available for sorting results - do not include 'sort:' syntax in query strings. Query strings should contain only search criteria (e.g., 'org:google language:python'), not sorting instructions.`
+func generateContextToolsetInstructions(_ *inventory.Inventory) string {
+ return "Always call 'get_me' first to understand current user permissions and context."
+}
- allInstructions := []string{baseInstruction}
- allInstructions = append(allInstructions, instructions...)
+func generateIssuesToolsetInstructions(_ *inventory.Inventory) string {
+ return `## Issues
- return strings.Join(allInstructions, " ")
+Check 'list_issue_types' first for organizations to use proper issue types. Use 'search_issues' before creating new issues to avoid duplicates. Always set 'state_reason' when closing issues.`
}
-// getToolsetInstructions returns specific instructions for individual toolsets
-func getToolsetInstructions(toolset string, enabledToolsets []string) string {
- switch toolset {
- case "pull_requests":
- pullRequestInstructions := `## Pull Requests
+func generatePullRequestsToolsetInstructions(inv *inventory.Inventory) string {
+ instructions := `## Pull Requests
PR review workflow: Always use 'pull_request_review_write' with method 'create' to create a pending review, then 'add_comment_to_pending_review' to add comments, and finally 'pull_request_review_write' with method 'submit_pending' to submit the review for complex reviews with line-specific comments.`
- if slices.Contains(enabledToolsets, "repos") {
- pullRequestInstructions += `
+
+ if inv.HasToolset("repos") {
+ instructions += `
Before creating a pull request, search for pull request templates in the repository. Template files are called pull_request_template.md or they're located in '.github/PULL_REQUEST_TEMPLATE' directory. Use the template content to structure the PR description and then call create_pull_request tool.`
- }
- return pullRequestInstructions
- case "issues":
- return `## Issues
+ }
+ return instructions
+}
+
+func generateDiscussionsToolsetInstructions(_ *inventory.Inventory) string {
+ return `## Discussions
-Check 'list_issue_types' first for organizations to use proper issue types. Use 'search_issues' before creating new issues to avoid duplicates. Always set 'state_reason' when closing issues.`
- case "discussions":
- return `## Discussions
-
Use 'list_discussion_categories' to understand available categories before creating discussions. Filter by category for better organization.`
- case "projects":
- return `## Projects
+}
+
+func generateProjectsToolsetInstructions(_ *inventory.Inventory) string {
+ return `## Projects
Workflow: 1) list_project_fields (get field IDs), 2) list_project_items (with pagination), 3) optional updates.
@@ -137,7 +103,4 @@ Common Qualifier Glossary (items):
Never:
- Infer field IDs; fetch via list_project_fields.
- Drop 'fields' param on subsequent pages if field values are needed.`
- default:
- return ""
- }
}
diff --git a/pkg/inventory/builder.go b/pkg/inventory/builder.go
index 58abb8ad1..ff2d06d5d 100644
--- a/pkg/inventory/builder.go
+++ b/pkg/inventory/builder.go
@@ -34,12 +34,13 @@ type Builder struct {
deprecatedAliases map[string]string
// Configuration options (processed at Build time)
- readOnly bool
- toolsetIDs []string // raw input, processed at Build()
- toolsetIDsIsNil bool // tracks if nil was passed (nil = defaults)
- additionalTools []string // raw input, processed at Build()
- featureChecker FeatureFlagChecker
- filters []ToolFilter // filters to apply to all tools
+ readOnly bool
+ toolsetIDs []string // raw input, processed at Build()
+ toolsetIDsIsNil bool // tracks if nil was passed (nil = defaults)
+ additionalTools []string // raw input, processed at Build()
+ featureChecker FeatureFlagChecker
+ filters []ToolFilter // filters to apply to all tools
+ generateInstructions bool
}
// NewBuilder creates a new Builder.
@@ -84,6 +85,11 @@ func (b *Builder) WithReadOnly(readOnly bool) *Builder {
return b
}
+func (b *Builder) WithServerInstructions() *Builder {
+ b.generateInstructions = true
+ return b
+}
+
// WithToolsets specifies which toolsets should be enabled.
// Special keywords:
// - "all": enables all toolsets
@@ -202,6 +208,10 @@ func (b *Builder) Build() (*Inventory, error) {
}
}
+ if b.generateInstructions {
+ r.instructions = generateInstructions(r)
+ }
+
return r, nil
}
diff --git a/pkg/inventory/instructions.go b/pkg/inventory/instructions.go
new file mode 100644
index 000000000..e4524eb43
--- /dev/null
+++ b/pkg/inventory/instructions.go
@@ -0,0 +1,43 @@
+package inventory
+
+import (
+ "os"
+ "strings"
+)
+
+// generateInstructions creates server instructions based on enabled toolsets
+func generateInstructions(inv *Inventory) string {
+ // For testing - add a flag to disable instructions
+ if os.Getenv("DISABLE_INSTRUCTIONS") == "true" {
+ return "" // Baseline mode
+ }
+
+ var instructions []string
+
+ // Base instruction with context management
+ baseInstruction := `The GitHub MCP Server provides tools to interact with GitHub platform.
+
+Tool selection guidance:
+ 1. Use 'list_*' tools for broad, simple retrieval and pagination of all items of a type (e.g., all issues, all PRs, all branches) with basic filtering.
+ 2. Use 'search_*' tools for targeted queries with specific criteria, keywords, or complex filters (e.g., issues with certain text, PRs by author, code containing functions).
+
+Context management:
+ 1. Use pagination whenever possible with batches of 5-10 items.
+ 2. Use minimal_output parameter set to true if the full information is not needed to accomplish a task.
+
+Tool usage guidance:
+ 1. For 'search_*' tools: Use separate 'sort' and 'order' parameters if available for sorting results - do not include 'sort:' syntax in query strings. Query strings should contain only search criteria (e.g., 'org:google language:python'), not sorting instructions.`
+
+ instructions = append(instructions, baseInstruction)
+
+ // Collect instructions from each enabled toolset
+ for _, toolset := range inv.AvailableToolsets() {
+ if toolset.InstructionsFunc != nil {
+ if toolsetInstructions := toolset.InstructionsFunc(inv); toolsetInstructions != "" {
+ instructions = append(instructions, toolsetInstructions)
+ }
+ }
+ }
+
+ return strings.Join(instructions, " ")
+}
diff --git a/pkg/inventory/instructions_test.go b/pkg/inventory/instructions_test.go
new file mode 100644
index 000000000..7a347c1e2
--- /dev/null
+++ b/pkg/inventory/instructions_test.go
@@ -0,0 +1,205 @@
+package inventory
+
+import (
+ "os"
+ "strings"
+ "testing"
+)
+
+// createTestInventory creates an inventory with the specified toolsets for testing.
+func createTestInventory(toolsets []ToolsetMetadata) *Inventory {
+ // Create tools for each toolset so they show up in AvailableToolsets()
+ var tools []ServerTool
+ for _, ts := range toolsets {
+ tools = append(tools, ServerTool{
+ Toolset: ts,
+ })
+ }
+
+ inv, _ := NewBuilder().
+ SetTools(tools).
+ Build()
+
+ return inv
+}
+
+func TestGenerateInstructions(t *testing.T) {
+ tests := []struct {
+ name string
+ toolsets []ToolsetMetadata
+ expectedEmpty bool
+ }{
+ {
+ name: "empty toolsets",
+ toolsets: []ToolsetMetadata{},
+ expectedEmpty: false, // base instructions are always included
+ },
+ {
+ name: "toolset with instructions",
+ toolsets: []ToolsetMetadata{
+ {
+ ID: "test",
+ Description: "Test toolset",
+ InstructionsFunc: func(_ *Inventory) string {
+ return "Test instructions"
+ },
+ },
+ },
+ expectedEmpty: false,
+ },
+ {
+ name: "toolset without instructions",
+ toolsets: []ToolsetMetadata{
+ {
+ ID: "test",
+ Description: "Test toolset",
+ },
+ },
+ expectedEmpty: false, // base instructions still included
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ inv := createTestInventory(tt.toolsets)
+ result := generateInstructions(inv)
+
+ if tt.expectedEmpty {
+ if result != "" {
+ t.Errorf("Expected empty instructions but got: %s", result)
+ }
+ } else {
+ if result == "" {
+ t.Errorf("Expected non-empty instructions but got empty result")
+ }
+ }
+ })
+ }
+}
+
+func TestGenerateInstructionsWithDisableFlag(t *testing.T) {
+ tests := []struct {
+ name string
+ disableEnvValue string
+ expectedEmpty bool
+ }{
+ {
+ name: "DISABLE_INSTRUCTIONS=true returns empty",
+ disableEnvValue: "true",
+ expectedEmpty: true,
+ },
+ {
+ name: "DISABLE_INSTRUCTIONS=false returns normal instructions",
+ disableEnvValue: "false",
+ expectedEmpty: false,
+ },
+ {
+ name: "DISABLE_INSTRUCTIONS unset returns normal instructions",
+ disableEnvValue: "",
+ expectedEmpty: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ // Save original env value
+ originalValue := os.Getenv("DISABLE_INSTRUCTIONS")
+ defer func() {
+ if originalValue == "" {
+ os.Unsetenv("DISABLE_INSTRUCTIONS")
+ } else {
+ os.Setenv("DISABLE_INSTRUCTIONS", originalValue)
+ }
+ }()
+
+ // Set test env value
+ if tt.disableEnvValue == "" {
+ os.Unsetenv("DISABLE_INSTRUCTIONS")
+ } else {
+ os.Setenv("DISABLE_INSTRUCTIONS", tt.disableEnvValue)
+ }
+
+ inv := createTestInventory([]ToolsetMetadata{
+ {ID: "test", Description: "Test"},
+ })
+ result := generateInstructions(inv)
+
+ if tt.expectedEmpty {
+ if result != "" {
+ t.Errorf("Expected empty instructions but got: %s", result)
+ }
+ } else {
+ if result == "" {
+ t.Errorf("Expected non-empty instructions but got empty result")
+ }
+ }
+ })
+ }
+}
+
+func TestToolsetInstructionsFunc(t *testing.T) {
+ tests := []struct {
+ name string
+ toolsets []ToolsetMetadata
+ expectedToContain string
+ notExpectedToContain string
+ }{
+ {
+ name: "toolset with context-aware instructions includes extra text when dependency present",
+ toolsets: []ToolsetMetadata{
+ {ID: "repos", Description: "Repos"},
+ {
+ ID: "pull_requests",
+ Description: "PRs",
+ InstructionsFunc: func(inv *Inventory) string {
+ instructions := "PR base instructions"
+ if inv.HasToolset("repos") {
+ instructions += " PR template instructions"
+ }
+ return instructions
+ },
+ },
+ },
+ expectedToContain: "PR template instructions",
+ },
+ {
+ name: "toolset with context-aware instructions excludes extra text when dependency missing",
+ toolsets: []ToolsetMetadata{
+ {
+ ID: "pull_requests",
+ Description: "PRs",
+ InstructionsFunc: func(inv *Inventory) string {
+ instructions := "PR base instructions"
+ if inv.HasToolset("repos") {
+ instructions += " PR template instructions"
+ }
+ return instructions
+ },
+ },
+ },
+ notExpectedToContain: "PR template instructions",
+ },
+ {
+ name: "toolset without InstructionsFunc returns no toolset-specific instructions",
+ toolsets: []ToolsetMetadata{
+ {ID: "test", Description: "Test without instructions"},
+ },
+ notExpectedToContain: "## Test",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ inv := createTestInventory(tt.toolsets)
+ result := generateInstructions(inv)
+
+ if tt.expectedToContain != "" && !strings.Contains(result, tt.expectedToContain) {
+ t.Errorf("Expected result to contain '%s', but it did not. Result: %s", tt.expectedToContain, result)
+ }
+
+ if tt.notExpectedToContain != "" && strings.Contains(result, tt.notExpectedToContain) {
+ t.Errorf("Did not expect result to contain '%s', but it did. Result: %s", tt.notExpectedToContain, result)
+ }
+ })
+ }
+}
diff --git a/pkg/inventory/registry.go b/pkg/inventory/registry.go
index 885617b43..e4113b452 100644
--- a/pkg/inventory/registry.go
+++ b/pkg/inventory/registry.go
@@ -58,6 +58,8 @@ type Inventory struct {
filters []ToolFilter
// unrecognizedToolsets holds toolset IDs that were requested but don't match any registered toolsets
unrecognizedToolsets []string
+ // server instructions hold high-level instructions for agents to use the server effectively
+ instructions string
}
// UnrecognizedToolsets returns toolset IDs that were passed to WithToolsets but don't
@@ -292,3 +294,7 @@ func (r *Inventory) AvailableToolsets(exclude ...ToolsetID) []ToolsetMetadata {
}
return result
}
+
+func (r *Inventory) Instructions() string {
+ return r.instructions
+}
diff --git a/pkg/inventory/server_tool.go b/pkg/inventory/server_tool.go
index 095bedf2b..752a4c2bd 100644
--- a/pkg/inventory/server_tool.go
+++ b/pkg/inventory/server_tool.go
@@ -31,6 +31,9 @@ type ToolsetMetadata struct {
// Use the base name without size suffix, e.g., "repo" not "repo-16".
// See https://primer.style/foundations/icons for available icons.
Icon string
+ // InstructionsFunc optionally returns instructions for this toolset.
+ // It receives the inventory so it can check what other toolsets are enabled.
+ InstructionsFunc func(inv *Inventory) string
}
// Icons returns MCP Icon objects for this toolset, or nil if no icon is set.
diff --git a/script/licenses b/script/licenses
index 5aa8ec16b..23686315b 100755
--- a/script/licenses
+++ b/script/licenses
@@ -18,13 +18,9 @@
# depending on the license.
set -e
-# Pinned version for CI reproducibility, latest for local development
+# Pinned version for reproducibility
# See: https://github.com/cli/cli/pull/11161
-if [ "$CI" = "true" ]; then
- go install github.com/google/go-licenses@5348b744d0983d85713295ea08a20cca1654a45e # v2.0.1
-else
- go install github.com/google/go-licenses@latest
-fi
+go install github.com/google/go-licenses/v2@v2.0.1
# actions/setup-go does not setup the installed toolchain to be preferred over the system install,
# which causes go-licenses to raise "Package ... does not have module info" errors in CI.