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 PathUsing 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: workflow 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: project 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.