diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 000000000..7b114b004 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,313 @@ +"use client" + +import type React from "react" + +import { useState } from "react" +import { useRouter, useSearchParams } from "next/navigation" +import { createClient } from "@/lib/supabase" +import Link from "next/link" + +export default function OwnerInfoContent() { + const router = useRouter() + const searchParams = useSearchParams() + + const [formData, setFormData] = useState({ + full_name: "", + date_of_birth: "", + id_number: "", + phone: "", + email: "", + nationality: "", + gender: "male", + has_partners: false, + partners: [] as Array<{ name: string; id_number: string; ownership_percentage: string }>, + agree_terms: false, + agree_privacy: false, + }) + + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState("") + + const handleInputChange = (e: React.ChangeEvent) => { + const { name, value, type } = e.target + const val = type === "checkbox" ? (e.target as HTMLInputElement).checked : value + + setFormData((prev) => ({ + ...prev, + [name]: val, + })) + } + + const handleAddPartner = () => { + setFormData((prev) => ({ + ...prev, + partners: [ + ...prev.partners, + { + name: "", + id_number: "", + ownership_percentage: "", + }, + ], + })) + } + + const handlePartnerChange = (index: number, field: string, value: string) => { + setFormData((prev) => { + const newPartners = [...prev.partners] + newPartners[index] = { ...newPartners[index], [field]: value } + return { ...prev, partners: newPartners } + }) + } + + const handleRemovePartner = (index: number) => { + setFormData((prev) => ({ + ...prev, + partners: prev.partners.filter((_, i) => i !== index), + })) + } + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setError("") + + if (!formData.agree_terms || !formData.agree_privacy) { + setError("ظٹط¬ط¨ ط§ظ„ظ…ظˆط§ظپظ‚ط© ط¹ظ„ظ‰ ط§ظ„ط´ط±ظˆط· ظˆط§ظ„ط®طµظˆطµظٹط©") + return + } + + setIsLoading(true) + + try { + const supabase = createClient() + const { + data: { user }, + } = await supabase.auth.getUser() + + if (!user) { + throw new Error("User not authenticated") + } + + router.push("/dashboard/owner/setup/payment") + } catch (err) { + setError("ط­ط¯ط« ط®ط·ط£. ظٹط±ط¬ظ‰ ط§ظ„ظ…ط­ط§ظˆظ„ط© ظ…ط±ط© ط£ط®ط±ظ‰.") + } finally { + setIsLoading(false) + } + } + + return ( +
+
+
+

ظ…ط¹ظ„ظˆظ…ط§طھ ط§ظ„ظ…ط§ظ„ظƒ

+

ط§ظ„ط®ط·ظˆط© 4 ظ…ظ† 4

+
+ +
+ {error &&
{error}
} + + {/* ط§ظ„ط¨ظٹط§ظ†ط§طھ ط§ظ„ط£ط³ط§ط³ظٹط© */} +
+
+ + +
+
+ + +
+
+ + {/* ط§ظ„ظ‡ظˆظٹط© ظˆط§ظ„ط§طھطµط§ظ„ */} +
+
+ + +
+
+ + +
+
+ + +
+
+ + {/* ط§ظ„ط¬ظ†ط³ظٹط© ظˆط§ظ„ظ†ظˆط¹ */} +
+
+ + +
+
+ + +
+
+ + {/* ظ‡ظ„ ظ„ط¯ظٹظƒ ط´ط±ظƒط§ط، */} +
+ +
+ + {/* ط¨ظٹط§ظ†ط§طھ ط§ظ„ط´ط±ظƒط§ط، */} + {formData.has_partners && ( +
+ {formData.partners.map((partner, index) => ( +
+
+ handlePartnerChange(index, "name", e.target.value)} + className="px-3 py-2 border border-border rounded-lg bg-background text-foreground focus:outline-none focus:border-primary" + /> + handlePartnerChange(index, "id_number", e.target.value)} + className="px-3 py-2 border border-border rounded-lg bg-background text-foreground focus:outline-none focus:border-primary" + /> + handlePartnerChange(index, "ownership_percentage", e.target.value)} + className="px-3 py-2 border border-border rounded-lg bg-background text-foreground focus:outline-none focus:border-primary" + min="0" + max="100" + /> +
+ +
+ ))} + +
+ )} + + {/* ط§ظ„ظ…ظˆط§ظپظ‚ط§طھ */} +
+ + +
+ + {/* ط£ط²ط±ط§ط± ط§ظ„طھظ†ظ‚ظ„ */} +
+ + ط§ظ„ط³ط§ط¨ظ‚ + + +
+
+
+
+ ) +}{ + "image": "mcr.microsoft.com/devcontainers/universal:2", + "features": {} +} diff --git a/.github/workflows/license-check.yml b/.github/workflows/license-check.yml index ce2fa26fb..940773275 100644 --- a/.github/workflows/license-check.yml +++ b/.github/workflows/license-check.yml @@ -25,8 +25,12 @@ jobs: steps: - name: Check out code uses: actions/checkout@v6 - with: - ref: ${{ github.head_ref }} + + # Check out the actual PR branch so we can push changes back if needed + - name: Check out PR branch + env: + GH_TOKEN: ${{ github.token }} + run: gh pr checkout ${{ github.event.pull_request.number }} - name: Set up Go uses: actions/setup-go@v6 @@ -63,7 +67,7 @@ jobs: - name: Check if already commented if: steps.changes.outcome == 'failure' && steps.push.outcome == 'failure' id: check_comment - uses: actions/github-script@v7 + uses: actions/github-script@v8 with: script: | const { data: comments } = await github.rest.issues.listComments({ @@ -81,7 +85,7 @@ jobs: - name: Comment with instructions if cannot push if: steps.changes.outcome == 'failure' && steps.push.outcome == 'failure' && steps.check_comment.outputs.already_commented == 'false' - uses: actions/github-script@v7 + uses: actions/github-script@v8 with: script: | await github.rest.issues.createComment({ diff --git a/README.md b/Y3tik Khairha similarity index 79% rename from README.md rename to Y3tik Khairha index 059256aaa..c5fd498ea 100644 --- a/README.md +++ b/Y3tik Khairha @@ -1,182 +1,313 @@ -[![Go Report Card](https://goreportcard.com/badge/github.com/github/github-mcp-server)](https://goreportcard.com/report/github.com/github/github-mcp-server) - -# GitHub MCP Server - -The GitHub MCP Server connects AI tools directly to GitHub's platform. This gives AI agents, assistants, and chatbots the ability to read repositories and code files, manage issues and PRs, analyze code, and automate workflows. All through natural language interactions. - -### Use Cases - -- Repository Management: Browse and query code, search files, analyze commits, and understand project structure across any repository you have access to. -- Issue & PR Automation: Create, update, and manage issues and pull requests. Let AI help triage bugs, review code changes, and maintain project boards. -- CI/CD & Workflow Intelligence: Monitor GitHub Actions workflow runs, analyze build failures, manage releases, and get insights into your development pipeline. -- Code Analysis: Examine security findings, review Dependabot alerts, understand code patterns, and get comprehensive insights into your codebase. -- Team Collaboration: Access discussions, manage notifications, analyze team activity, and streamline processes for your team. - -Built for developers who want to connect their AI tools to GitHub context and capabilities, from simple natural language queries to complex multi-step agent workflows. - ---- - -## Remote GitHub MCP Server - -[![Install in VS Code](https://img.shields.io/badge/VS_Code-Install_Server-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2F%22%7D) [![Install in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install_Server-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2F%22%7D&quality=insiders) - -The remote GitHub MCP Server is hosted by GitHub and provides the easiest method for getting up and running. If your MCP host does not support remote MCP servers, don't worry! You can use the [local version of the GitHub MCP Server](https://github.com/github/github-mcp-server?tab=readme-ov-file#local-github-mcp-server) instead. - -### Prerequisites - -1. A compatible MCP host with remote server support (VS Code 1.101+, Claude Desktop, Cursor, Windsurf, etc.) -2. Any applicable [policies enabled](https://github.com/github/github-mcp-server/blob/main/docs/policies-and-governance.md) - -### Install in VS Code - -For quick installation, use one of the one-click install buttons above. Once you complete that flow, toggle Agent mode (located by the Copilot Chat text input) and the server will start. Make sure you're using [VS Code 1.101](https://code.visualstudio.com/updates/v1_101) or [later](https://code.visualstudio.com/updates) for remote MCP and OAuth support. +"use client" + +import type React from "react" + +import { useState } from "react" +import { useRouter, useSearchParams } from "next/navigation" +import { createClient } from "@/lib/supabase" +import Link from "next/link" + +export default function OwnerInfoContent() { + const router = useRouter() + const searchParams = useSearchParams() + + const [formData, setFormData] = useState({ + full_name: "", + date_of_birth: "", + id_number: "", + phone: "", + email: "", + nationality: "", + gender: "male", + has_partners: false, + partners: [] as Array<{ name: string; id_number: string; ownership_percentage: string }>, + agree_terms: false, + agree_privacy: false, + }) + + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState("") + + const handleInputChange = (e: React.ChangeEvent) => { + const { name, value, type } = e.target + const val = type === "checkbox" ? (e.target as HTMLInputElement).checked : value + + setFormData((prev) => ({ + ...prev, + [name]: val, + })) + } -Alternatively, to manually configure VS Code, choose the appropriate JSON block from the examples below and add it to your host configuration: + const handleAddPartner = () => { + setFormData((prev) => ({ + ...prev, + partners: [ + ...prev.partners, + { + name: "", + id_number: "", + ownership_percentage: "", + }, + ], + })) + } - - - - - - - -
Using OAuthUsing a GitHub PAT
VS Code (version 1.101 or greater)
+ const handlePartnerChange = (index: number, field: string, value: string) => { + setFormData((prev) => { + const newPartners = [...prev.partners] + newPartners[index] = { ...newPartners[index], [field]: value } + return { ...prev, partners: newPartners } + }) + } -```json -{ - "servers": { - "github": { - "type": "http", - "url": "https://api.githubcopilot.com/mcp/" - } + const handleRemovePartner = (index: number) => { + setFormData((prev) => ({ + ...prev, + partners: prev.partners.filter((_, i) => i !== index), + })) } -} -``` - + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setError("") -```json -{ - "servers": { - "github": { - "type": "http", - "url": "https://api.githubcopilot.com/mcp/", - "headers": { - "Authorization": "Bearer ${input:github_mcp_pat}" - } - } - }, - "inputs": [ - { - "type": "promptString", - "id": "github_mcp_pat", - "description": "GitHub Personal Access Token", - "password": true + if (!formData.agree_terms || !formData.agree_privacy) { + setError("ظٹط¬ط¨ ط§ظ„ظ…ظˆط§ظپظ‚ط© ط¹ظ„ظ‰ ط§ظ„ط´ط±ظˆط· ظˆط§ظ„ط®طµظˆطµظٹط©") + return } - ] -} -``` - -
- -### Install in other MCP hosts -- **[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 Web, Claude Desktop and Claude Code CLI -- **[Cursor](/docs/installation-guides/install-cursor.md)** - Installation guide for Cursor IDE -- **[Windsurf](/docs/installation-guides/install-windsurf.md)** - Installation guide for Windsurf IDE -> **Note:** Each MCP host application needs to configure a GitHub App or OAuth App to support remote access via OAuth. Any host application that supports remote MCP servers should support the remote GitHub server with PAT authentication. Configuration details and support levels vary by host. Make sure to refer to the host application's documentation for more info. - -### Configuration - -#### Toolset configuration - -See [Remote Server Documentation](docs/remote-server.md) for full details on remote server configuration, toolsets, headers, and advanced usage. This file provides comprehensive instructions and examples for connecting, customizing, and installing the remote GitHub MCP Server in VS Code and other MCP hosts. - -When no toolsets are specified, [default toolsets](#default-toolset) are used. + setIsLoading(true) -#### GitHub Enterprise + try { + const supabase = createClient() + const { + data: { user }, + } = await supabase.auth.getUser() -##### GitHub Enterprise Cloud with data residency (ghe.com) - -GitHub Enterprise Cloud can also make use of the remote server. - -Example for `https://octocorp.ghe.com` with GitHub PAT token: -``` -{ - ... - "proxima-github": { - "type": "http", - "url": "https://copilot-api.octocorp.ghe.com/mcp", - "headers": { - "Authorization": "Bearer ${input:github_mcp_pat}" + if (!user) { + throw new Error("User not authenticated") } - }, - ... -} -``` - -> **Note:** When using OAuth with GitHub Enterprise with VS Code and GitHub Copilot, you also need to configure your VS Code settings to point to your GitHub Enterprise instance - see [Authenticate from VS Code](https://docs.github.com/en/enterprise-cloud@latest/copilot/how-tos/configure-personal-settings/authenticate-to-ghecom) - -##### GitHub Enterprise Server - -GitHub Enterprise Server does not support remote server hosting. Please refer to [GitHub Enterprise Server and Enterprise Cloud with data residency (ghe.com)](#github-enterprise-server-and-enterprise-cloud-with-data-residency-ghecom) from the local server configuration. - ---- - -## Local GitHub MCP Server - -[![Install with Docker in VS Code](https://img.shields.io/badge/VS_Code-Install_Server-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=github&inputs=%5B%7B%22id%22%3A%22github_token%22%2C%22type%22%3A%22promptString%22%2C%22description%22%3A%22GitHub%20Personal%20Access%20Token%22%2C%22password%22%3Atrue%7D%5D&config=%7B%22command%22%3A%22docker%22%2C%22args%22%3A%5B%22run%22%2C%22-i%22%2C%22--rm%22%2C%22-e%22%2C%22GITHUB_PERSONAL_ACCESS_TOKEN%22%2C%22ghcr.io%2Fgithub%2Fgithub-mcp-server%22%5D%2C%22env%22%3A%7B%22GITHUB_PERSONAL_ACCESS_TOKEN%22%3A%22%24%7Binput%3Agithub_token%7D%22%7D%7D) [![Install with Docker in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install_Server-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=github&inputs=%5B%7B%22id%22%3A%22github_token%22%2C%22type%22%3A%22promptString%22%2C%22description%22%3A%22GitHub%20Personal%20Access%20Token%22%2C%22password%22%3Atrue%7D%5D&config=%7B%22command%22%3A%22docker%22%2C%22args%22%3A%5B%22run%22%2C%22-i%22%2C%22--rm%22%2C%22-e%22%2C%22GITHUB_PERSONAL_ACCESS_TOKEN%22%2C%22ghcr.io%2Fgithub%2Fgithub-mcp-server%22%5D%2C%22env%22%3A%7B%22GITHUB_PERSONAL_ACCESS_TOKEN%22%3A%22%24%7Binput%3Agithub_token%7D%22%7D%7D&quality=insiders) - -### Prerequisites - -1. To run the server in a container, you will need to have [Docker](https://www.docker.com/) installed. -2. Once Docker is installed, you will also need to ensure Docker is running. The image is public; if you get errors on pull, you may have an expired token and need to `docker logout ghcr.io`. -3. Lastly you will need to [Create a GitHub Personal Access Token](https://github.com/settings/personal-access-tokens/new). -The MCP server can use many of the GitHub APIs, so enable the permissions that you feel comfortable granting your AI tools (to learn more about access tokens, please check out the [documentation](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens)). - -
Handling PATs Securely - -### Environment Variables (Recommended) -To keep your GitHub PAT secure and reusable across different MCP hosts: - -1. **Store your PAT in environment variables** - ```bash - export GITHUB_PAT=your_token_here - ``` - Or create a `.env` file: - ```env - GITHUB_PAT=your_token_here - ``` - -2. **Protect your `.env` file** - ```bash - # Add to .gitignore to prevent accidental commits - echo ".env" >> .gitignore - ``` - -3. **Reference the token in configurations** - ```bash - # CLI usage - claude mcp update github -e GITHUB_PERSONAL_ACCESS_TOKEN=$GITHUB_PAT - - # In config files (where supported) - "env": { - "GITHUB_PERSONAL_ACCESS_TOKEN": "$GITHUB_PAT" - } - ``` - -> **Note**: Environment variable support varies by host app and IDE. Some applications (like Windsurf) require hardcoded tokens in config files. -### Token Security Best Practices + router.push("/dashboard/owner/setup/payment") + } catch (err) { + setError("ط­ط¯ط« ط®ط·ط£. ظٹط±ط¬ظ‰ ط§ظ„ظ…ط­ط§ظˆظ„ط© ظ…ط±ط© ط£ط®ط±ظ‰.") + } finally { + setIsLoading(false) + } + } -- **Minimum scopes**: Only grant necessary permissions - - `repo` - Repository operations - - `read:packages` - Docker image access - - `read:org` - Organization team access -- **Separate tokens**: Use different PATs for different projects/environments -- **Regular rotation**: Update tokens periodically -- **Never commit**: Keep tokens out of version control + return ( +
+
+
+

ظ…ط¹ظ„ظˆظ…ط§طھ ط§ظ„ظ…ط§ظ„ظƒ

+

ط§ظ„ط®ط·ظˆط© 4 ظ…ظ† 4

+
+ +
+ {error &&
{error}
} + + {/* ط§ظ„ط¨ظٹط§ظ†ط§طھ ط§ظ„ط£ط³ط§ط³ظٹط© */} +
+
+ + +
+
+ + +
+
+ + {/* ط§ظ„ظ‡ظˆظٹط© ظˆط§ظ„ط§طھطµط§ظ„ */} +
+
+ + +
+
+ + +
+
+ + +
+
+ + {/* ط§ظ„ط¬ظ†ط³ظٹط© ظˆط§ظ„ظ†ظˆط¹ */} +
+
+ + +
+
+ + +
+
+ + {/* ظ‡ظ„ ظ„ط¯ظٹظƒ ط´ط±ظƒط§ط، */} +
+ +
+ + {/* ط¨ظٹط§ظ†ط§طھ ط§ظ„ط´ط±ظƒط§ط، */} + {formData.has_partners && ( +
+ {formData.partners.map((partner, index) => ( +
+
+ handlePartnerChange(index, "name", e.target.value)} + className="px-3 py-2 border border-border rounded-lg bg-background text-foreground focus:outline-none focus:border-primary" + /> + handlePartnerChange(index, "id_number", e.target.value)} + className="px-3 py-2 border border-border rounded-lg bg-background text-foreground focus:outline-none focus:border-primary" + /> + handlePartnerChange(index, "ownership_percentage", e.target.value)} + className="px-3 py-2 border border-border rounded-lg bg-background text-foreground focus:outline-none focus:border-primary" + min="0" + max="100" + /> +
+ +
+ ))} + +
+ )} + + {/* ط§ظ„ظ…ظˆط§ظپظ‚ط§طھ */} +
+ + +
+ + {/* ط£ط²ط±ط§ط± ط§ظ„طھظ†ظ‚ظ„ */} +
+ + ط§ظ„ط³ط§ط¨ظ‚ + + +
+
+
+
+ ) +}f version control - **File permissions**: Restrict access to config files containing tokens ```bash chmod 600 ~/.your-app/config.json @@ -490,56 +621,26 @@ The following sets of tools are available: workflow Actions -- **actions_get** - Get details of GitHub Actions resources (workflows, workflow runs, jobs, and artifacts) - - `method`: The method to execute (string, required) - - `owner`: Repository owner (string, required) - - `repo`: Repository name (string, 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) - -- **actions_list** - List GitHub Actions workflows in a repository - - `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) - - `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. - - 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 - - `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) - - **cancel_workflow_run** - Cancel 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) - **delete_workflow_run_logs** - Delete workflow 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) - **download_workflow_run_artifact** - Download workflow artifact + - **Required OAuth Scopes**: `repo` - `artifact_id`: The unique identifier of the artifact (number, required) - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) - **get_job_logs** - Get 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) - `owner`: Repository owner (string, required) @@ -548,31 +649,26 @@ The following sets of tools are available: - `run_id`: Workflow run ID (required when using failed_only) (number, optional) - `tail_lines`: Number of lines to return from the end of the log (number, optional) -- **get_job_logs** - Get GitHub Actions workflow job logs - - `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`: 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) @@ -581,6 +677,7 @@ The following sets of tools are available: - `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) @@ -588,6 +685,7 @@ The following sets of tools are available: - `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) @@ -599,22 +697,26 @@ The following sets of tools are available: - `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) @@ -628,11 +730,15 @@ The following sets of tools are available: codescan Code Security - **get_code_scanning_alert** - Get code scanning alert + - **Required OAuth Scopes**: `security_events` + - **Accepted OAuth Scopes**: `repo`, `security_events` - `alertNumber`: The number of the alert. (number, required) - `owner`: The owner of the repository. (string, required) - `repo`: The name of the repository. (string, required) - **list_code_scanning_alerts** - List code scanning alerts + - **Required OAuth Scopes**: `security_events` + - **Accepted OAuth Scopes**: `repo`, `security_events` - `owner`: The owner of the repository. (string, required) - `ref`: The Git reference for the results you want to list. (string, optional) - `repo`: The name of the repository. (string, required) @@ -650,10 +756,14 @@ The following sets of tools are available: - No parameters required - **get_team_members** - Get team members + - **Required OAuth Scopes**: `read:org` + - **Accepted OAuth Scopes**: `admin:org`, `read:org`, `write:org` - `org`: Organization login (owner) that contains the team. (string, required) - `team_slug`: Team slug (string, required) - **get_teams** - Get teams + - **Required OAuth Scopes**: `read:org` + - **Accepted OAuth Scopes**: `admin:org`, `read:org`, `write:org` - `user`: Username to get teams for. If not provided, uses the authenticated user. (string, optional)
@@ -663,11 +773,15 @@ The following sets of tools are available: dependabot Dependabot - **get_dependabot_alert** - Get dependabot alert + - **Required OAuth Scopes**: `security_events` + - **Accepted OAuth Scopes**: `repo`, `security_events` - `alertNumber`: The number of the alert. (number, required) - `owner`: The owner of the repository. (string, required) - `repo`: The name of the repository. (string, required) - **list_dependabot_alerts** - List dependabot alerts + - **Required OAuth Scopes**: `security_events` + - **Accepted OAuth Scopes**: `repo`, `security_events` - `owner`: The owner of the repository. (string, required) - `repo`: The name of the repository. (string, required) - `severity`: Filter dependabot alerts by severity (string, optional) @@ -680,11 +794,13 @@ The following sets of tools are available: comment-discussion Discussions - **get_discussion** - Get discussion + - **Required OAuth Scopes**: `repo` - `discussionNumber`: Discussion Number (number, required) - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) - **get_discussion_comments** - Get discussion comments + - **Required OAuth Scopes**: `repo` - `after`: Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs. (string, optional) - `discussionNumber`: Discussion Number (number, required) - `owner`: Repository owner (string, required) @@ -692,10 +808,12 @@ The following sets of tools are available: - `repo`: Repository name (string, required) - **list_discussion_categories** - List discussion categories + - **Required OAuth Scopes**: `repo` - `owner`: Repository owner (string, required) - `repo`: Repository name. If not provided, discussion categories will be queried at the organisation level. (string, optional) - **list_discussions** - List discussions + - **Required OAuth Scopes**: `repo` - `after`: Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs. (string, optional) - `category`: Optional filter by discussion category ID. If provided, only discussions with this category are listed. (string, optional) - `direction`: Order direction. (string, optional) @@ -711,6 +829,7 @@ The following sets of tools are available: logo-gist Gists - **create_gist** - Create Gist + - **Required OAuth Scopes**: `gist` - `content`: Content for simple single-file gist creation (string, required) - `description`: Description of the gist (string, optional) - `filename`: Filename for simple single-file gist creation (string, required) @@ -726,6 +845,7 @@ The following sets of tools are available: - `username`: GitHub username (omit for authenticated user's gists) (string, optional) - **update_gist** - Update Gist + - **Required OAuth Scopes**: `gist` - `content`: Content for the file (string, required) - `description`: Updated description of the gist (string, optional) - `filename`: Filename to update or create (string, required) @@ -738,6 +858,7 @@ The following sets of tools are available: git-branch Git - **get_repository_tree** - Get repository tree + - **Required OAuth Scopes**: `repo` - `owner`: Repository owner (username or organization) (string, required) - `path_filter`: Optional path prefix to filter the tree results (e.g., 'src/' to only show files in the src directory) (string, optional) - `recursive`: Setting this parameter to true returns the objects or subtrees referenced by the tree. Default is false (boolean, optional) @@ -751,22 +872,27 @@ The following sets of tools are available: issue-opened Issues - **add_issue_comment** - Add comment to issue + - **Required OAuth Scopes**: `repo` - `body`: Comment content (string, required) - `issue_number`: Issue number to comment on (number, required) - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) - **assign_copilot_to_issue** - Assign Copilot to issue - - `issueNumber`: Issue number (number, required) + - **Required OAuth Scopes**: `repo` + - `base_ref`: Git reference (e.g., branch) that the agent will start its work from. If not specified, defaults to the repository's default branch (string, optional) + - `issue_number`: Issue number (number, required) - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) - **get_label** - Get a specific label from a repository. + - **Required OAuth Scopes**: `repo` - `name`: Label name. (string, required) - `owner`: Repository owner (username or organization name) (string, required) - `repo`: Repository name (string, required) - **issue_read** - Get issue details + - **Required OAuth Scopes**: `repo` - `issue_number`: The number of the issue (number, required) - `method`: The read operation to perform on a single issue. Options are: @@ -781,6 +907,7 @@ The following sets of tools are available: - `repo`: The name of the repository (string, required) - **issue_write** - Create or update issue. + - **Required OAuth Scopes**: `repo` - `assignees`: Usernames to assign to this issue (string[], optional) - `body`: Issue body content (string, optional) - `duplicate_of`: Issue number that this issue is a duplicate of. Only used when state_reason is 'duplicate'. (number, optional) @@ -800,9 +927,12 @@ The following sets of tools are available: - `type`: Type of this issue. Only use if the repository has issue types configured. Use list_issue_types tool to get valid type values for the organization. If the repository doesn't support issue types, omit this parameter. (string, optional) - **list_issue_types** - List available issue types + - **Required OAuth Scopes**: `read:org` + - **Accepted OAuth Scopes**: `admin:org`, `read:org`, `write:org` - `owner`: The organization owner of the repository (string, required) - **list_issues** - List issues + - **Required OAuth Scopes**: `repo` - `after`: Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs. (string, optional) - `direction`: Order direction. If provided, the 'orderBy' also needs to be provided. (string, optional) - `labels`: Filter by labels (string[], optional) @@ -814,6 +944,7 @@ The following sets of tools are available: - `state`: Filter by state, by default both open and closed issues are returned when not provided (string, optional) - **search_issues** - Search issues + - **Required OAuth Scopes**: `repo` - `order`: Sort order (string, optional) - `owner`: Optional repository owner. If provided with repo, only issues for this repository are listed. (string, optional) - `page`: Page number for pagination (min 1) (number, optional) @@ -823,6 +954,7 @@ The following sets of tools are available: - `sort`: Sort field by number of matches of categories, defaults to best match (string, optional) - **sub_issue_write** - Change sub-issue + - **Required OAuth Scopes**: `repo` - `after_id`: The ID of the sub-issue to be prioritized after (either after_id OR before_id should be specified) (number, optional) - `before_id`: The ID of the sub-issue to be prioritized before (either after_id OR before_id should be specified) (number, optional) - `issue_number`: The number of the parent issue (number, required) @@ -844,11 +976,13 @@ The following sets of tools are available: tag Labels - **get_label** - Get a specific label from a repository. + - **Required OAuth Scopes**: `repo` - `name`: Label name. (string, required) - `owner`: Repository owner (username or organization name) (string, required) - `repo`: Repository name (string, required) - **label_write** - Write operations on repository labels. + - **Required OAuth Scopes**: `repo` - `color`: Label color as 6-character hex code without '#' prefix (e.g., 'f29513'). Required for 'create', optional for 'update'. (string, optional) - `description`: Label description text. Optional for 'create' and 'update'. (string, optional) - `method`: Operation to perform: 'create', 'update', or 'delete' (string, required) @@ -858,6 +992,7 @@ The following sets of tools are available: - `repo`: Repository name (string, required) - **list_label** - List labels from a repository + - **Required OAuth Scopes**: `repo` - `owner`: Repository owner (username or organization name) - required for all operations (string, required) - `repo`: Repository name - required for all operations (string, required) @@ -868,13 +1003,16 @@ The following sets of tools are available: bell Notifications - **dismiss_notification** - Dismiss notification + - **Required OAuth Scopes**: `notifications` - `state`: The new state of the notification (read/done) (string, required) - `threadID`: The ID of the notification thread (string, required) - **get_notification_details** - Get notification details + - **Required OAuth Scopes**: `notifications` - `notificationID`: The ID of the notification (string, required) - **list_notifications** - List notifications + - **Required OAuth Scopes**: `notifications` - `before`: Only show notifications updated before the given time (ISO 8601 format) (string, optional) - `filter`: Filter notifications to, use default unless specified. Read notifications are ones that have already been acknowledged by the user. Participating notifications are those that the user is directly involved in, such as issues or pull requests they have commented on or created. (string, optional) - `owner`: Optional repository owner. If provided with repo, only notifications for this repository are listed. (string, optional) @@ -884,15 +1022,18 @@ The following sets of tools are available: - `since`: Only show notifications updated after the given time (ISO 8601 format) (string, optional) - **manage_notification_subscription** - Manage notification subscription + - **Required OAuth Scopes**: `notifications` - `action`: Action to perform: ignore, watch, or delete the notification subscription. (string, required) - `notificationID`: The ID of the notification thread. (string, required) - **manage_repository_notification_subscription** - Manage repository notification subscription + - **Required OAuth Scopes**: `notifications` - `action`: Action to perform: ignore, watch, or delete the repository notification subscription. (string, required) - `owner`: The account owner of the repository. (string, required) - `repo`: The name of the repository. (string, required) - **mark_all_notifications_read** - Mark all notifications as read + - **Required OAuth Scopes**: `notifications` - `lastReadAt`: Describes the last point that notifications were checked (optional). Default: Now (string, optional) - `owner`: Optional repository owner. If provided with repo, only notifications for this repository are marked as read. (string, optional) - `repo`: Optional repository name. If provided with owner, only notifications for this repository are marked as read. (string, optional) @@ -904,6 +1045,8 @@ The following sets of tools are available: organization Organizations - **search_orgs** - Search organizations + - **Required OAuth Scopes**: `read:org` + - **Accepted OAuth Scopes**: `admin:org`, `read:org`, `write:org` - `order`: Sort order (string, optional) - `page`: Page number for pagination (min 1) (number, optional) - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) @@ -917,6 +1060,7 @@ 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) @@ -924,23 +1068,30 @@ The following sets of tools are available: - `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 + - **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) @@ -948,6 +1099,8 @@ The following sets of tools are available: - `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) @@ -956,6 +1109,8 @@ The following sets of tools are available: - `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) @@ -966,6 +1121,8 @@ The following sets of tools are available: - `query`: Query string for advanced filtering of project items using GitHub's project filtering syntax. (string, optional) - **list_projects** - List projects + - **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) @@ -974,6 +1131,7 @@ The following sets of tools are available: - `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) - **update_project_item** - Update project item + - **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) @@ -987,6 +1145,7 @@ The following sets of tools are available: git-pull-request Pull Requests - **add_comment_to_pending_review** - Add review comment to the requester's latest pending pull request review + - **Required OAuth Scopes**: `repo` - `body`: The text of the review comment (string, required) - `line`: The line of the blob in the pull request diff that the comment applies to. For multi-line comments, the last line of the range (number, optional) - `owner`: Repository owner (string, required) @@ -999,6 +1158,7 @@ The following sets of tools are available: - `subjectType`: The level at which the comment is targeted (string, required) - **create_pull_request** - Open new pull request + - **Required OAuth Scopes**: `repo` - `base`: Branch to merge into (string, required) - `body`: PR description (string, optional) - `draft`: Create as draft PR (boolean, optional) @@ -1009,6 +1169,7 @@ The following sets of tools are available: - `title`: PR title (string, required) - **list_pull_requests** - List pull requests + - **Required OAuth Scopes**: `repo` - `base`: Filter by base branch (string, optional) - `direction`: Sort direction (string, optional) - `head`: Filter by head user/org and branch (string, optional) @@ -1020,6 +1181,7 @@ The following sets of tools are available: - `state`: Filter by state (string, optional) - **merge_pull_request** - Merge pull request + - **Required OAuth Scopes**: `repo` - `commit_message`: Extra detail for merge commit (string, optional) - `commit_title`: Title for merge commit (string, optional) - `merge_method`: Merge method (string, optional) @@ -1028,6 +1190,7 @@ The following sets of tools are available: - `repo`: Repository name (string, required) - **pull_request_read** - Get details for a single pull request + - **Required OAuth Scopes**: `repo` - `method`: Action to specify what pull request data needs to be retrieved from GitHub. Possible options: 1. get - Get details of a specific pull request. @@ -1045,6 +1208,7 @@ The following sets of tools are available: - `repo`: Repository name (string, required) - **pull_request_review_write** - Write operations (create, submit, delete) on pull request reviews. + - **Required OAuth Scopes**: `repo` - `body`: Review comment text (string, optional) - `commitID`: SHA of commit to review (string, optional) - `event`: Review action to perform. (string, optional) @@ -1054,11 +1218,13 @@ The following sets of tools are available: - `repo`: Repository name (string, required) - **request_copilot_review** - Request Copilot review + - **Required OAuth Scopes**: `repo` - `owner`: Repository owner (string, required) - `pullNumber`: Pull request number (number, required) - `repo`: Repository name (string, required) - **search_pull_requests** - Search pull requests + - **Required OAuth Scopes**: `repo` - `order`: Sort order (string, optional) - `owner`: Optional repository owner. If provided with repo, only pull requests for this repository are listed. (string, optional) - `page`: Page number for pagination (min 1) (number, optional) @@ -1068,6 +1234,7 @@ The following sets of tools are available: - `sort`: Sort field by number of matches of categories, defaults to best match (string, optional) - **update_pull_request** - Edit pull request + - **Required OAuth Scopes**: `repo` - `base`: New base branch name (string, optional) - `body`: New description (string, optional) - `draft`: Mark pull request as draft (true) or ready for review (false) (boolean, optional) @@ -1080,6 +1247,7 @@ The following sets of tools are available: - `title`: New title (string, optional) - **update_pull_request_branch** - Update pull request branch + - **Required OAuth Scopes**: `repo` - `expectedHeadSha`: The expected SHA of the pull request's HEAD ref (string, optional) - `owner`: Repository owner (string, required) - `pullNumber`: Pull request number (number, required) @@ -1092,12 +1260,14 @@ The following sets of tools are available: repo Repositories - **create_branch** - Create branch + - **Required OAuth Scopes**: `repo` - `branch`: Name for new branch (string, required) - `from_branch`: Source branch (defaults to repo default) (string, optional) - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) - **create_or_update_file** - Create or update file + - **Required OAuth Scopes**: `repo` - `branch`: Branch to create/update the file in (string, required) - `content`: Content of the file (string, required) - `message`: Commit message (string, required) @@ -1107,6 +1277,7 @@ The following sets of tools are available: - `sha`: The blob SHA of the file being replaced. (string, optional) - **create_repository** - Create repository + - **Required OAuth Scopes**: `repo` - `autoInit`: Initialize with README (boolean, optional) - `description`: Repository description (string, optional) - `name`: Repository name (string, required) @@ -1114,6 +1285,7 @@ The following sets of tools are available: - `private`: Whether repo should be private (boolean, optional) - **delete_file** - Delete file + - **Required OAuth Scopes**: `repo` - `branch`: Branch to delete the file from (string, required) - `message`: Commit message (string, required) - `owner`: Repository owner (username or organization) (string, required) @@ -1121,11 +1293,13 @@ The following sets of tools are available: - `repo`: Repository name (string, required) - **fork_repository** - Fork repository + - **Required OAuth Scopes**: `repo` - `organization`: Organization to fork to (string, optional) - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) - **get_commit** - Get commit details + - **Required OAuth Scopes**: `repo` - `include_diff`: Whether to include file diffs and stats in the response. Default is true. (boolean, optional) - `owner`: Repository owner (string, required) - `page`: Page number for pagination (min 1) (number, optional) @@ -1134,6 +1308,7 @@ The following sets of tools are available: - `sha`: Commit SHA, branch name, or tag name (string, required) - **get_file_contents** - Get file or directory contents + - **Required OAuth Scopes**: `repo` - `owner`: Repository owner (username or organization) (string, required) - `path`: Path to file/directory (string, optional) - `ref`: Accepts optional git refs such as `refs/tags/{tag}`, `refs/heads/{branch}` or `refs/pull/{pr_number}/head` (string, optional) @@ -1141,26 +1316,31 @@ The following sets of tools are available: - `sha`: Accepts optional commit SHA. If specified, it will be used instead of ref (string, optional) - **get_latest_release** - Get latest release + - **Required OAuth Scopes**: `repo` - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) - **get_release_by_tag** - Get a release by tag name + - **Required OAuth Scopes**: `repo` - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) - `tag`: Tag name (e.g., 'v1.0.0') (string, required) - **get_tag** - Get tag details + - **Required OAuth Scopes**: `repo` - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) - `tag`: Tag name (string, required) - **list_branches** - List branches + - **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) - **list_commits** - List commits + - **Required OAuth Scopes**: `repo` - `author`: Author username or email address to filter commits by (string, optional) - `owner`: Repository owner (string, required) - `page`: Page number for pagination (min 1) (number, optional) @@ -1169,18 +1349,21 @@ The following sets of tools are available: - `sha`: Commit SHA, branch or tag name to list commits of. If not provided, uses the default branch of the repository. If a commit SHA is provided, will list commits up to that SHA. (string, optional) - **list_releases** - List releases + - **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) - **list_tags** - List tags + - **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) - **push_files** - Push files to repository + - **Required OAuth Scopes**: `repo` - `branch`: Branch to push to (string, required) - `files`: Array of file objects to push, each object with path (string) and content (string) (object[], required) - `message`: Commit message (string, required) @@ -1188,6 +1371,7 @@ The following sets of tools are available: - `repo`: Repository name (string, required) - **search_code** - Search code + - **Required OAuth Scopes**: `repo` - `order`: Sort order for results (string, optional) - `page`: Page number for pagination (min 1) (number, optional) - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) @@ -1195,6 +1379,7 @@ The following sets of tools are available: - `sort`: Sort field ('indexed' only) (string, optional) - **search_repositories** - Search repositories + - **Required OAuth Scopes**: `repo` - `minimal_output`: Return minimal repository information (default: true). When false, returns full GitHub API repository objects. (boolean, optional) - `order`: Sort order (string, optional) - `page`: Page number for pagination (min 1) (number, optional) @@ -1209,11 +1394,15 @@ The following sets of tools are available: shield-lock Secret Protection - **get_secret_scanning_alert** - Get secret scanning alert + - **Required OAuth Scopes**: `security_events` + - **Accepted OAuth Scopes**: `repo`, `security_events` - `alertNumber`: The number of the alert. (number, required) - `owner`: The owner of the repository. (string, required) - `repo`: The name of the repository. (string, required) - **list_secret_scanning_alerts** - List secret scanning alerts + - **Required OAuth Scopes**: `security_events` + - **Accepted OAuth Scopes**: `repo`, `security_events` - `owner`: The owner of the repository. (string, required) - `repo`: The name of the repository. (string, required) - `resolution`: Filter by resolution (string, optional) @@ -1227,9 +1416,13 @@ The following sets of tools are available: shield Security Advisories - **get_global_security_advisory** - Get a global security advisory + - **Required OAuth Scopes**: `security_events` + - **Accepted OAuth Scopes**: `repo`, `security_events` - `ghsaId`: GitHub Security Advisory ID (format: GHSA-xxxx-xxxx-xxxx). (string, required) - **list_global_security_advisories** - List global security advisories + - **Required OAuth Scopes**: `security_events` + - **Accepted OAuth Scopes**: `repo`, `security_events` - `affects`: Filter advisories by affected package or version (e.g. "package1,package2@1.0.0"). (string, optional) - `cveId`: Filter by CVE ID. (string, optional) - `cwes`: Filter by Common Weakness Enumeration IDs (e.g. ["79", "284", "22"]). (string[], optional) @@ -1243,12 +1436,16 @@ The following sets of tools are available: - `updated`: Filter by update date or date range (ISO 8601 date or range). (string, optional) - **list_org_repository_security_advisories** - List org repository security advisories + - **Required OAuth Scopes**: `security_events` + - **Accepted OAuth Scopes**: `repo`, `security_events` - `direction`: Sort direction. (string, optional) - `org`: The organization login. (string, required) - `sort`: Sort field. (string, optional) - `state`: Filter by advisory state. (string, optional) - **list_repository_security_advisories** - List repository security advisories + - **Required OAuth Scopes**: `security_events` + - **Accepted OAuth Scopes**: `repo`, `security_events` - `direction`: Sort direction. (string, optional) - `owner`: The owner of the repository. (string, required) - `repo`: The name of the repository. (string, required) @@ -1262,6 +1459,7 @@ The following sets of tools are available: star Stargazers - **list_starred_repositories** - List starred repositories + - **Required OAuth Scopes**: `repo` - `direction`: The direction to sort the results by. (string, optional) - `page`: Page number for pagination (min 1) (number, optional) - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) @@ -1269,10 +1467,12 @@ The following sets of tools are available: - `username`: Username to list starred repositories for. Defaults to the authenticated user. (string, optional) - **star_repository** - Star repository + - **Required OAuth Scopes**: `repo` - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) - **unstar_repository** - Unstar repository + - **Required OAuth Scopes**: `repo` - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) @@ -1283,6 +1483,7 @@ The following sets of tools are available: people Users - **search_users** - Search users + - **Required OAuth Scopes**: `repo` - `order`: Sort order (string, optional) - `page`: Page number for pagination (min 1) (number, optional) - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) diff --git a/cmd/github-mcp-server/generate_docs.go b/cmd/github-mcp-server/generate_docs.go index b40e3e2f4..a458c04b6 100644 --- a/cmd/github-mcp-server/generate_docs.go +++ b/cmd/github-mcp-server/generate_docs.go @@ -1,6 +1,7 @@ package main import ( + "context" "fmt" "net/url" "os" @@ -11,7 +12,6 @@ import ( "github.com/github/github-mcp-server/pkg/inventory" "github.com/github/github-mcp-server/pkg/translations" "github.com/google/jsonschema-go/jsonschema" - "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/spf13/cobra" ) @@ -50,8 +50,8 @@ func generateReadmeDocs(readmePath string) error { // Create translation helper t, _ := translations.TranslationHelper() - // Build inventory - stateless, no dependencies needed for doc generation - r := github.NewInventory(t).Build() + // (not available to regular users) while including tools with FeatureFlagDisable. + r := github.NewInventory(t).WithToolsets([]string{"all"}).Build() // Generate toolsets documentation toolsetsDoc := generateToolsetsDoc(r) @@ -153,9 +153,7 @@ func generateToolsetsDoc(i *inventory.Inventory) string { } func generateToolsDoc(r *inventory.Inventory) string { - // AllTools() returns tools sorted by toolset ID then tool name. - // We iterate once, grouping by toolset as we encounter them. - tools := r.AllTools() + tools := r.AvailableTools(context.Background()) if len(tools) == 0 { return "" } @@ -190,7 +188,7 @@ func generateToolsDoc(r *inventory.Inventory) string { currentToolsetID = tool.Toolset.ID currentToolsetIcon = tool.Toolset.Icon } - writeToolDoc(&toolBuf, tool.Tool) + writeToolDoc(&toolBuf, tool) toolBuf.WriteString("\n\n") } @@ -200,40 +198,26 @@ func generateToolsDoc(r *inventory.Inventory) string { return buf.String() } -func formatToolsetName(name string) string { - switch name { - case "pull_requests": - return "Pull Requests" - case "repos": - return "Repositories" - case "code_security": - return "Code Security" - case "secret_protection": - return "Secret Protection" - case "orgs": - return "Organizations" - default: - // Fallback: capitalize first letter and replace underscores with spaces - parts := strings.Split(name, "_") - for i, part := range parts { - if len(part) > 0 { - parts[i] = strings.ToUpper(string(part[0])) + part[1:] - } +func writeToolDoc(buf *strings.Builder, tool inventory.ServerTool) { + // Tool name (no icon - section header already has the toolset icon) + fmt.Fprintf(buf, "- **%s** - %s\n", tool.Tool.Name, tool.Tool.Annotations.Title) + + // OAuth scopes if present + if len(tool.RequiredScopes) > 0 { + fmt.Fprintf(buf, " - **Required OAuth Scopes**: `%s`\n", strings.Join(tool.RequiredScopes, "`, `")) + + // Only show accepted scopes if they differ from required scopes + if len(tool.AcceptedScopes) > 0 && !scopesEqual(tool.RequiredScopes, tool.AcceptedScopes) { + fmt.Fprintf(buf, " - **Accepted OAuth Scopes**: `%s`\n", strings.Join(tool.AcceptedScopes, "`, `")) } - return strings.Join(parts, " ") } -} - -func writeToolDoc(buf *strings.Builder, tool mcp.Tool) { - // Tool name (no icon - section header already has the toolset icon) - fmt.Fprintf(buf, "- **%s** - %s\n", tool.Name, tool.Annotations.Title) // Parameters - if tool.InputSchema == nil { + if tool.Tool.InputSchema == nil { buf.WriteString(" - No parameters required") return } - schema, ok := tool.InputSchema.(*jsonschema.Schema) + schema, ok := tool.Tool.InputSchema.(*jsonschema.Schema) if !ok || schema == nil { buf.WriteString(" - No parameters required") return @@ -282,6 +266,28 @@ func writeToolDoc(buf *strings.Builder, tool mcp.Tool) { } } +// scopesEqual checks if two scope slices contain the same elements (order-independent) +func scopesEqual(a, b []string) bool { + if len(a) != len(b) { + return false + } + + // Create a map for quick lookup + aMap := make(map[string]bool, len(a)) + for _, scope := range a { + aMap[scope] = true + } + + // Check if all elements in b are in a + for _, scope := range b { + if !aMap[scope] { + return false + } + } + + return true +} + func contains(slice []string, item string) bool { for _, s := range slice { if s == item { @@ -343,14 +349,13 @@ func generateRemoteToolsetsDoc() string { // Add "all" toolset first (special case) allIcon := octiconImg("apps", "../") - fmt.Fprintf(&buf, "| %s
all | All available GitHub MCP tools | https://api.githubcopilot.com/mcp/ | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%%7B%%22type%%22%%3A%%20%%22http%%22%%2C%%22url%%22%%3A%%20%%22https%%3A%%2F%%2Fapi.githubcopilot.com%%2Fmcp%%2F%%22%%7D) | [read-only](https://api.githubcopilot.com/mcp/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%%7B%%22type%%22%%3A%%20%%22http%%22%%2C%%22url%%22%%3A%%20%%22https%%3A%%2F%%2Fapi.githubcopilot.com%%2Fmcp%%2Freadonly%%22%%7D) |\n", allIcon) + fmt.Fprintf(&buf, "| %s
`all` | All available GitHub MCP tools | https://api.githubcopilot.com/mcp/ | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%%7B%%22type%%22%%3A%%20%%22http%%22%%2C%%22url%%22%%3A%%20%%22https%%3A%%2F%%2Fapi.githubcopilot.com%%2Fmcp%%2F%%22%%7D) | [read-only](https://api.githubcopilot.com/mcp/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%%7B%%22type%%22%%3A%%20%%22http%%22%%2C%%22url%%22%%3A%%20%%22https%%3A%%2F%%2Fapi.githubcopilot.com%%2Fmcp%%2Freadonly%%22%%7D) |\n", allIcon) // AvailableToolsets() returns toolsets that have tools, sorted by ID // Exclude context (handled separately) and dynamic (internal only) for _, ts := range r.AvailableToolsets("context", "dynamic") { idStr := string(ts.ID) - formattedName := formatToolsetName(idStr) apiURL := fmt.Sprintf("https://api.githubcopilot.com/mcp/x/%s", idStr) readonlyURL := fmt.Sprintf("https://api.githubcopilot.com/mcp/x/%s/readonly", idStr) @@ -366,9 +371,9 @@ func generateRemoteToolsetsDoc() string { readonlyInstallLink := fmt.Sprintf("[Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-%s&config=%s)", idStr, readonlyConfig) icon := octiconImg(ts.Icon, "../") - fmt.Fprintf(&buf, "| %s
%s | %s | %s | %s | [read-only](%s) | %s |\n", + fmt.Fprintf(&buf, "| %s
`%s` | %s | %s | %s | [read-only](%s) | %s |\n", icon, - formattedName, + idStr, ts.Description, apiURL, installLink, @@ -391,7 +396,6 @@ func generateRemoteOnlyToolsetsDoc() string { for _, ts := range github.RemoteOnlyToolsets() { idStr := string(ts.ID) - formattedName := formatToolsetName(idStr) apiURL := fmt.Sprintf("https://api.githubcopilot.com/mcp/x/%s", idStr) readonlyURL := fmt.Sprintf("https://api.githubcopilot.com/mcp/x/%s/readonly", idStr) @@ -407,9 +411,9 @@ func generateRemoteOnlyToolsetsDoc() string { readonlyInstallLink := fmt.Sprintf("[Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-%s&config=%s)", idStr, readonlyConfig) icon := octiconImg(ts.Icon, "../") - fmt.Fprintf(&buf, "| %s
%s | %s | %s | %s | [read-only](%s) | %s |\n", + fmt.Fprintf(&buf, "| %s
`%s` | %s | %s | %s | [read-only](%s) | %s |\n", icon, - formattedName, + idStr, ts.Description, apiURL, installLink, diff --git a/cmd/github-mcp-server/helpers.go b/cmd/github-mcp-server/helpers.go new file mode 100644 index 000000000..c5f498813 --- /dev/null +++ b/cmd/github-mcp-server/helpers.go @@ -0,0 +1,29 @@ +package main + +import "strings" + +// formatToolsetName converts a toolset ID to a human-readable name. +// Used by both generate_docs.go and list_scopes.go for consistent formatting. +func formatToolsetName(name string) string { + switch name { + case "pull_requests": + return "Pull Requests" + case "repos": + return "Repositories" + case "code_security": + return "Code Security" + case "secret_protection": + return "Secret Protection" + case "orgs": + return "Organizations" + default: + // Fallback: capitalize first letter and replace underscores with spaces + parts := strings.Split(name, "_") + for i, part := range parts { + if len(part) > 0 { + parts[i] = strings.ToUpper(string(part[0])) + part[1:] + } + } + return strings.Join(parts, " ") + } +} diff --git a/cmd/github-mcp-server/list_scopes.go b/cmd/github-mcp-server/list_scopes.go new file mode 100644 index 000000000..2d1817500 --- /dev/null +++ b/cmd/github-mcp-server/list_scopes.go @@ -0,0 +1,291 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "os" + "sort" + "strings" + + "github.com/github/github-mcp-server/pkg/github" + "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +// ToolScopeInfo contains scope information for a single tool. +type ToolScopeInfo struct { + Name string `json:"name"` + Toolset string `json:"toolset"` + ReadOnly bool `json:"read_only"` + RequiredScopes []string `json:"required_scopes"` + AcceptedScopes []string `json:"accepted_scopes,omitempty"` +} + +// ScopesOutput is the full output structure for the list-scopes command. +type ScopesOutput struct { + Tools []ToolScopeInfo `json:"tools"` + UniqueScopes []string `json:"unique_scopes"` + ScopesByTool map[string][]string `json:"scopes_by_tool"` + ToolsByScope map[string][]string `json:"tools_by_scope"` + EnabledToolsets []string `json:"enabled_toolsets"` + ReadOnly bool `json:"read_only"` +} + +var listScopesCmd = &cobra.Command{ + Use: "list-scopes", + Short: "List required OAuth scopes for enabled tools", + Long: `List the required OAuth scopes for all enabled tools. + +This command creates an inventory based on the same flags as the stdio command +and outputs the required OAuth scopes for each enabled tool. This is useful for +determining what scopes a token needs to use specific tools. + +The output format can be controlled with the --output flag: + - text (default): Human-readable text output + - json: JSON output for programmatic use + - summary: Just the unique scopes needed + +Examples: + # List scopes for default toolsets + github-mcp-server list-scopes + + # List scopes for specific toolsets + github-mcp-server list-scopes --toolsets=repos,issues,pull_requests + + # List scopes for all toolsets + github-mcp-server list-scopes --toolsets=all + + # Output as JSON + github-mcp-server list-scopes --output=json + + # Just show unique scopes needed + github-mcp-server list-scopes --output=summary`, + RunE: func(_ *cobra.Command, _ []string) error { + return runListScopes() + }, +} + +func init() { + listScopesCmd.Flags().StringP("output", "o", "text", "Output format: text, json, or summary") + _ = viper.BindPFlag("list-scopes-output", listScopesCmd.Flags().Lookup("output")) + + rootCmd.AddCommand(listScopesCmd) +} + +// formatScopeDisplay formats a scope string for display, handling empty scopes. +func formatScopeDisplay(scope string) string { + if scope == "" { + return "(no scope required for public read access)" + } + return scope +} + +func runListScopes() error { + // Get toolsets configuration (same logic as stdio command) + var enabledToolsets []string + if viper.IsSet("toolsets") { + if err := viper.UnmarshalKey("toolsets", &enabledToolsets); err != nil { + return fmt.Errorf("failed to unmarshal toolsets: %w", err) + } + } + // else: enabledToolsets stays nil, meaning "use defaults" + + // Get specific tools (similar to toolsets) + var enabledTools []string + if viper.IsSet("tools") { + if err := viper.UnmarshalKey("tools", &enabledTools); err != nil { + return fmt.Errorf("failed to unmarshal tools: %w", err) + } + } + + readOnly := viper.GetBool("read-only") + outputFormat := viper.GetString("list-scopes-output") + + // Create translation helper + t, _ := translations.TranslationHelper() + + // Build inventory using the same logic as the stdio server + inventoryBuilder := github.NewInventory(t). + WithReadOnly(readOnly) + + // Configure toolsets (same as stdio) + if enabledToolsets != nil { + inventoryBuilder = inventoryBuilder.WithToolsets(enabledToolsets) + } + + // Configure specific tools + if len(enabledTools) > 0 { + inventoryBuilder = inventoryBuilder.WithTools(enabledTools) + } + + inv := inventoryBuilder.Build() + + // Collect all tools and their scopes + output := collectToolScopes(inv, readOnly) + + // Output based on format + switch outputFormat { + case "json": + return outputJSON(output) + case "summary": + return outputSummary(output) + default: + return outputText(output) + } +} + +func collectToolScopes(inv *inventory.Inventory, readOnly bool) ScopesOutput { + var tools []ToolScopeInfo + scopeSet := make(map[string]bool) + scopesByTool := make(map[string][]string) + toolsByScope := make(map[string][]string) + + // Get all available tools from the inventory + // Use context.Background() for feature flag evaluation + availableTools := inv.AvailableTools(context.Background()) + + for _, serverTool := range availableTools { + tool := serverTool.Tool + + // Get scope information directly from ServerTool + requiredScopes := serverTool.RequiredScopes + acceptedScopes := serverTool.AcceptedScopes + + // Determine if tool is read-only + isReadOnly := serverTool.IsReadOnly() + + toolInfo := ToolScopeInfo{ + Name: tool.Name, + Toolset: string(serverTool.Toolset.ID), + ReadOnly: isReadOnly, + RequiredScopes: requiredScopes, + AcceptedScopes: acceptedScopes, + } + tools = append(tools, toolInfo) + + // Track unique scopes + for _, s := range requiredScopes { + scopeSet[s] = true + toolsByScope[s] = append(toolsByScope[s], tool.Name) + } + + // Track scopes by tool + scopesByTool[tool.Name] = requiredScopes + } + + // Sort tools by name + sort.Slice(tools, func(i, j int) bool { + return tools[i].Name < tools[j].Name + }) + + // Get unique scopes as sorted slice + var uniqueScopes []string + for s := range scopeSet { + uniqueScopes = append(uniqueScopes, s) + } + sort.Strings(uniqueScopes) + + // Sort tools within each scope + for scope := range toolsByScope { + sort.Strings(toolsByScope[scope]) + } + + // Get enabled toolsets as string slice + toolsetIDs := inv.ToolsetIDs() + toolsetIDStrs := make([]string, len(toolsetIDs)) + for i, id := range toolsetIDs { + toolsetIDStrs[i] = string(id) + } + + return ScopesOutput{ + Tools: tools, + UniqueScopes: uniqueScopes, + ScopesByTool: scopesByTool, + ToolsByScope: toolsByScope, + EnabledToolsets: toolsetIDStrs, + ReadOnly: readOnly, + } +} + +func outputJSON(output ScopesOutput) error { + encoder := json.NewEncoder(os.Stdout) + encoder.SetIndent("", " ") + return encoder.Encode(output) +} + +func outputSummary(output ScopesOutput) error { + if len(output.UniqueScopes) == 0 { + fmt.Println("No OAuth scopes required for enabled tools.") + return nil + } + + fmt.Println("Required OAuth scopes for enabled tools:") + fmt.Println() + for _, scope := range output.UniqueScopes { + fmt.Printf(" %s\n", formatScopeDisplay(scope)) + } + fmt.Printf("\nTotal: %d unique scope(s)\n", len(output.UniqueScopes)) + return nil +} + +func outputText(output ScopesOutput) error { + fmt.Printf("OAuth Scopes for Enabled Tools\n") + fmt.Printf("==============================\n\n") + + fmt.Printf("Enabled Toolsets: %s\n", strings.Join(output.EnabledToolsets, ", ")) + fmt.Printf("Read-Only Mode: %v\n\n", output.ReadOnly) + + // Group tools by toolset + toolsByToolset := make(map[string][]ToolScopeInfo) + for _, tool := range output.Tools { + toolsByToolset[tool.Toolset] = append(toolsByToolset[tool.Toolset], tool) + } + + // Get sorted toolset names + var toolsetNames []string + for name := range toolsByToolset { + toolsetNames = append(toolsetNames, name) + } + sort.Strings(toolsetNames) + + for _, toolsetName := range toolsetNames { + tools := toolsByToolset[toolsetName] + fmt.Printf("## %s\n\n", formatToolsetName(toolsetName)) + + for _, tool := range tools { + rwIndicator := "📝" + if tool.ReadOnly { + rwIndicator = "👁" + } + + scopeStr := "(no scope required)" + if len(tool.RequiredScopes) > 0 { + scopeStr = strings.Join(tool.RequiredScopes, ", ") + } + + fmt.Printf(" %s %s: %s\n", rwIndicator, tool.Name, scopeStr) + } + fmt.Println() + } + + // Summary + fmt.Println("## Summary") + fmt.Println() + if len(output.UniqueScopes) == 0 { + fmt.Println("No OAuth scopes required for enabled tools.") + } else { + fmt.Println("Unique scopes required:") + for _, scope := range output.UniqueScopes { + fmt.Printf(" • %s\n", formatScopeDisplay(scope)) + } + } + fmt.Printf("\nTotal: %d tools, %d unique scopes\n", len(output.Tools), len(output.UniqueScopes)) + + // Legend + fmt.Println("\nLegend: 👁 = read-only, 📝 = read-write") + + return nil +} diff --git a/docs/installation-guides/install-claude.md b/docs/installation-guides/install-claude.md index 1a5b789f4..ff1b26d70 100644 --- a/docs/installation-guides/install-claude.md +++ b/docs/installation-guides/install-claude.md @@ -28,22 +28,32 @@ echo -e ".env\n.mcp.json" >> .gitignore ### Remote Server Setup (Streamable HTTP) -1. Run the following command in the Claude Code CLI +> **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). + +1. Run the following command in the terminal (not in Claude Code CLI): ```bash -claude mcp add --transport http github https://api.githubcopilot.com/mcp -H "Authorization: Bearer YOUR_GITHUB_PAT" +claude mcp add-json github '{"type":"http","url":"https://api.githubcopilot.com/mcp","headers":{"Authorization":"Bearer YOUR_GITHUB_PAT"}}' ``` With an environment variable: ```bash -claude mcp add --transport http github https://api.githubcopilot.com/mcp -H "Authorization: Bearer $(grep GITHUB_PAT .env | cut -d '=' -f2)" +claude mcp add-json github '{"type":"http","url":"https://api.githubcopilot.com/mcp","headers":{"Authorization":"Bearer '"$(grep GITHUB_PAT .env | cut -d '=' -f2)"'"}}' ``` + +> **About the `--scope` flag** (optional): Use this to specify where the configuration is stored: +> - `local` (default): Available only to you in the current project (was called `project` in older versions) +> - `project`: Shared with everyone in the project via `.mcp.json` file +> - `user`: Available to you across all projects (was called `global` in older versions) +> +> Example: Add `--scope user` to the end of the command to make it available across all projects. + 2. Restart Claude Code 3. Run `claude mcp list` to see if the GitHub server is configured ### Local Server Setup (Docker required) ### With Docker -1. Run the following command in the Claude Code CLI: +1. Run the following command in the terminal (not in Claude Code CLI): ```bash claude mcp add github -e GITHUB_PERSONAL_ACCESS_TOKEN=YOUR_GITHUB_PAT -- docker run -i --rm -e GITHUB_PERSONAL_ACCESS_TOKEN ghcr.io/github/github-mcp-server ``` @@ -72,6 +82,19 @@ claude mcp list claude mcp get github ``` +### For Older Versions of Claude Code + +If you're using Claude Code version **2.1.0 or earlier**, use this legacy command format: + +```bash +claude mcp add --transport http github https://api.githubcopilot.com/mcp -H "Authorization: Bearer YOUR_GITHUB_PAT" +``` + +With an environment variable: +```bash +claude mcp add --transport http github https://api.githubcopilot.com/mcp -H "Authorization: Bearer $(grep GITHUB_PAT .env | cut -d '=' -f2)" +``` + --- ## Claude Desktop @@ -161,7 +184,4 @@ Add this codeblock to your `claude_desktop_config.json`: - The npm package `@modelcontextprotocol/server-github` is deprecated as of April 2025 - Remote server requires Streamable HTTP support (check your Claude version) -- Configuration scopes for Claude Code: - - `-s user`: Available across all projects - - `-s project`: Shared via `.mcp.json` file - - Default: `local` (current project only) +- For Claude Code configuration scopes, see the `--scope` flag documentation in the [Remote Server Setup](#remote-server-setup-streamable-http) section diff --git a/docs/installation-guides/install-rovo-dev-cli.md b/docs/installation-guides/install-rovo-dev-cli.md new file mode 100644 index 000000000..e6660bfe4 --- /dev/null +++ b/docs/installation-guides/install-rovo-dev-cli.md @@ -0,0 +1,32 @@ +# Install GitHub MCP Server in Rovo Dev CLI + +## Prerequisites + +1. Rovo Dev CLI installed (latest version) +2. [GitHub Personal Access Token](https://github.com/settings/personal-access-tokens/new) with appropriate scopes + +## MCP Server Setup + +Uses GitHub's hosted server at https://api.githubcopilot.com/mcp/. + +### Install steps + +1. Run `acli rovodev mcp` to open the MCP configuration for Rovo Dev CLI +2. Add configuration by following example below. +3. Replace `YOUR_GITHUB_PAT` with your actual [GitHub Personal Access Token](https://github.com/settings/tokens) +4. Save the file and restart Rovo Dev CLI with `acli rovodev` + +### Example configuration + +```json +{ + "mcpServers": { + "github": { + "url": "https://api.githubcopilot.com/mcp/", + "headers": { + "Authorization": "Bearer YOUR_GITHUB_PAT" + } + } + } +} +``` diff --git a/docs/remote-server.md b/docs/remote-server.md index d7d0f72b1..039d094fe 100644 --- a/docs/remote-server.md +++ b/docs/remote-server.md @@ -19,24 +19,24 @@ Below is a table of available toolsets for the remote GitHub MCP Server. Each to | Name | Description | API URL | 1-Click Install (VS Code) | Read-only Link | 1-Click Read-only Install (VS Code) | | ---- | ----------- | ------- | ------------------------- | -------------- | ----------------------------------- | -| apps
all | All available GitHub MCP tools | https://api.githubcopilot.com/mcp/ | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2F%22%7D) | [read-only](https://api.githubcopilot.com/mcp/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Freadonly%22%7D) | -| workflow
Actions | GitHub Actions workflows and CI/CD operations | https://api.githubcopilot.com/mcp/x/actions | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-actions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Factions%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/actions/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-actions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Factions%2Freadonly%22%7D) | -| codescan
Code Security | Code security related tools, such as GitHub Code Scanning | https://api.githubcopilot.com/mcp/x/code_security | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_security&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcode_security%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/code_security/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_security&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcode_security%2Freadonly%22%7D) | -| dependabot
Dependabot | Dependabot tools | https://api.githubcopilot.com/mcp/x/dependabot | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-dependabot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdependabot%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/dependabot/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-dependabot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdependabot%2Freadonly%22%7D) | -| comment-discussion
Discussions | GitHub Discussions related tools | https://api.githubcopilot.com/mcp/x/discussions | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-discussions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdiscussions%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/discussions/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-discussions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdiscussions%2Freadonly%22%7D) | -| logo-gist
Gists | GitHub Gist related tools | https://api.githubcopilot.com/mcp/x/gists | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-gists&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgists%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/gists/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-gists&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgists%2Freadonly%22%7D) | -| git-branch
Git | GitHub Git API related tools for low-level Git operations | https://api.githubcopilot.com/mcp/x/git | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-git&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgit%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/git/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-git&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgit%2Freadonly%22%7D) | -| issue-opened
Issues | GitHub Issues related tools | https://api.githubcopilot.com/mcp/x/issues | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-issues&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fissues%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/issues/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-issues&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fissues%2Freadonly%22%7D) | -| tag
Labels | GitHub Labels related tools | https://api.githubcopilot.com/mcp/x/labels | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-labels&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Flabels%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/labels/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-labels&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Flabels%2Freadonly%22%7D) | -| bell
Notifications | GitHub Notifications related tools | https://api.githubcopilot.com/mcp/x/notifications | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-notifications&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fnotifications%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/notifications/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-notifications&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fnotifications%2Freadonly%22%7D) | -| organization
Organizations | GitHub Organization related tools | https://api.githubcopilot.com/mcp/x/orgs | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-orgs&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Forgs%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/orgs/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-orgs&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Forgs%2Freadonly%22%7D) | -| project
Projects | GitHub Projects related tools | https://api.githubcopilot.com/mcp/x/projects | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-projects&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fprojects%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/projects/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-projects&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fprojects%2Freadonly%22%7D) | -| git-pull-request
Pull Requests | GitHub Pull Request related tools | https://api.githubcopilot.com/mcp/x/pull_requests | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-pull_requests&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fpull_requests%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/pull_requests/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-pull_requests&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fpull_requests%2Freadonly%22%7D) | -| repo
Repositories | GitHub Repository related tools | https://api.githubcopilot.com/mcp/x/repos | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-repos&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Frepos%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/repos/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-repos&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Frepos%2Freadonly%22%7D) | -| shield-lock
Secret Protection | Secret protection related tools, such as GitHub Secret Scanning | https://api.githubcopilot.com/mcp/x/secret_protection | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-secret_protection&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fsecret_protection%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/secret_protection/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-secret_protection&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fsecret_protection%2Freadonly%22%7D) | -| shield
Security Advisories | Security advisories related tools | https://api.githubcopilot.com/mcp/x/security_advisories | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-security_advisories&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fsecurity_advisories%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/security_advisories/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-security_advisories&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fsecurity_advisories%2Freadonly%22%7D) | -| star
Stargazers | GitHub Stargazers related tools | https://api.githubcopilot.com/mcp/x/stargazers | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-stargazers&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fstargazers%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/stargazers/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-stargazers&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fstargazers%2Freadonly%22%7D) | -| people
Users | GitHub User related tools | https://api.githubcopilot.com/mcp/x/users | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-users&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fusers%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/users/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-users&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fusers%2Freadonly%22%7D) | +| apps
`all` | All available GitHub MCP tools | https://api.githubcopilot.com/mcp/ | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2F%22%7D) | [read-only](https://api.githubcopilot.com/mcp/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Freadonly%22%7D) | +| workflow
`actions` | GitHub Actions workflows and CI/CD operations | https://api.githubcopilot.com/mcp/x/actions | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-actions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Factions%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/actions/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-actions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Factions%2Freadonly%22%7D) | +| codescan
`code_security` | Code security related tools, such as GitHub Code Scanning | https://api.githubcopilot.com/mcp/x/code_security | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_security&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcode_security%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/code_security/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_security&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcode_security%2Freadonly%22%7D) | +| dependabot
`dependabot` | Dependabot tools | https://api.githubcopilot.com/mcp/x/dependabot | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-dependabot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdependabot%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/dependabot/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-dependabot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdependabot%2Freadonly%22%7D) | +| comment-discussion
`discussions` | GitHub Discussions related tools | https://api.githubcopilot.com/mcp/x/discussions | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-discussions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdiscussions%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/discussions/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-discussions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdiscussions%2Freadonly%22%7D) | +| logo-gist
`gists` | GitHub Gist related tools | https://api.githubcopilot.com/mcp/x/gists | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-gists&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgists%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/gists/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-gists&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgists%2Freadonly%22%7D) | +| git-branch
`git` | GitHub Git API related tools for low-level Git operations | https://api.githubcopilot.com/mcp/x/git | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-git&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgit%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/git/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-git&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgit%2Freadonly%22%7D) | +| issue-opened
`issues` | GitHub Issues related tools | https://api.githubcopilot.com/mcp/x/issues | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-issues&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fissues%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/issues/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-issues&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fissues%2Freadonly%22%7D) | +| tag
`labels` | GitHub Labels related tools | https://api.githubcopilot.com/mcp/x/labels | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-labels&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Flabels%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/labels/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-labels&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Flabels%2Freadonly%22%7D) | +| bell
`notifications` | GitHub Notifications related tools | https://api.githubcopilot.com/mcp/x/notifications | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-notifications&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fnotifications%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/notifications/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-notifications&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fnotifications%2Freadonly%22%7D) | +| organization
`orgs` | GitHub Organization related tools | https://api.githubcopilot.com/mcp/x/orgs | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-orgs&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Forgs%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/orgs/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-orgs&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Forgs%2Freadonly%22%7D) | +| project
`projects` | GitHub Projects related tools | https://api.githubcopilot.com/mcp/x/projects | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-projects&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fprojects%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/projects/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-projects&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fprojects%2Freadonly%22%7D) | +| git-pull-request
`pull_requests` | GitHub Pull Request related tools | https://api.githubcopilot.com/mcp/x/pull_requests | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-pull_requests&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fpull_requests%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/pull_requests/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-pull_requests&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fpull_requests%2Freadonly%22%7D) | +| repo
`repos` | GitHub Repository related tools | https://api.githubcopilot.com/mcp/x/repos | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-repos&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Frepos%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/repos/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-repos&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Frepos%2Freadonly%22%7D) | +| shield-lock
`secret_protection` | Secret protection related tools, such as GitHub Secret Scanning | https://api.githubcopilot.com/mcp/x/secret_protection | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-secret_protection&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fsecret_protection%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/secret_protection/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-secret_protection&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fsecret_protection%2Freadonly%22%7D) | +| shield
`security_advisories` | Security advisories related tools | https://api.githubcopilot.com/mcp/x/security_advisories | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-security_advisories&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fsecurity_advisories%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/security_advisories/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-security_advisories&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fsecurity_advisories%2Freadonly%22%7D) | +| star
`stargazers` | GitHub Stargazers related tools | https://api.githubcopilot.com/mcp/x/stargazers | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-stargazers&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fstargazers%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/stargazers/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-stargazers&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fstargazers%2Freadonly%22%7D) | +| people
`users` | GitHub User related tools | https://api.githubcopilot.com/mcp/x/users | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-users&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fusers%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/users/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-users&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fusers%2Freadonly%22%7D) | ### Additional _Remote_ Server Toolsets @@ -46,9 +46,9 @@ These toolsets are only available in the remote GitHub MCP Server and are not in | Name | Description | API URL | 1-Click Install (VS Code) | Read-only Link | 1-Click Read-only Install (VS Code) | | ---- | ----------- | ------- | ------------------------- | -------------- | ----------------------------------- | -| copilot
Copilot | Copilot related tools | https://api.githubcopilot.com/mcp/x/copilot | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-copilot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcopilot%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/copilot/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-copilot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcopilot%2Freadonly%22%7D) | -| copilot
Copilot Spaces | Copilot Spaces tools | https://api.githubcopilot.com/mcp/x/copilot_spaces | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-copilot_spaces&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcopilot_spaces%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/copilot_spaces/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-copilot_spaces&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcopilot_spaces%2Freadonly%22%7D) | -| book
Github Support Docs Search | Retrieve documentation to answer GitHub product and support questions. Topics include: GitHub Actions Workflows, Authentication, ... | https://api.githubcopilot.com/mcp/x/github_support_docs_search | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-github_support_docs_search&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgithub_support_docs_search%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/github_support_docs_search/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-github_support_docs_search&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgithub_support_docs_search%2Freadonly%22%7D) | +| copilot
`copilot` | Copilot related tools | https://api.githubcopilot.com/mcp/x/copilot | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-copilot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcopilot%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/copilot/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-copilot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcopilot%2Freadonly%22%7D) | +| copilot
`copilot_spaces` | Copilot Spaces tools | https://api.githubcopilot.com/mcp/x/copilot_spaces | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-copilot_spaces&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcopilot_spaces%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/copilot_spaces/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-copilot_spaces&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcopilot_spaces%2Freadonly%22%7D) | +| book
`github_support_docs_search` | Retrieve documentation to answer GitHub product and support questions. Topics include: GitHub Actions Workflows, Authentication, ... | https://api.githubcopilot.com/mcp/x/github_support_docs_search | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-github_support_docs_search&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgithub_support_docs_search%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/github_support_docs_search/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-github_support_docs_search&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgithub_support_docs_search%2Freadonly%22%7D) | ### Optional Headers diff --git a/docs/scope-filtering.md b/docs/scope-filtering.md new file mode 100644 index 000000000..f29d631ca --- /dev/null +++ b/docs/scope-filtering.md @@ -0,0 +1,103 @@ +# PAT Scope Filtering + +The GitHub MCP Server automatically filters available tools based on your classic Personal Access Token's (PAT) OAuth scopes. This ensures you only see tools that your token has permission to use, reducing clutter and preventing errors from attempting operations your token can't perform. + +> **Note:** This feature applies to **classic PATs** (tokens starting with `ghp_`). Fine-grained PATs, GitHub App installation tokens, and server-to-server tokens don't support scope detection and show all tools. + +## How It Works + +When the server starts with a classic PAT, it makes a lightweight HTTP HEAD request to the GitHub API to discover your token's scopes from the `X-OAuth-Scopes` header. Tools that require scopes your token doesn't have are automatically hidden. + +**Example:** If your token only has `repo` and `gist` scopes, you won't see tools that require `admin:org`, `project`, or `notifications` scopes. + +## PAT vs OAuth Authentication + +| Authentication | Scope Handling | +|---------------|----------------| +| **Classic PAT** (`ghp_`) | Filters tools at startup based on token scopes—tools requiring unavailable scopes are hidden | +| **OAuth** (remote server only) | Uses OAuth scope challenges—when a tool needs a scope you haven't granted, you're prompted to authorize it | +| **Fine-grained PAT** (`github_pat_`) | No filtering—all tools shown, API enforces permissions | +| **GitHub App** (`ghs_`) | No filtering—all tools shown, permissions based on app installation | +| **Server-to-server** | No filtering—all tools shown, permissions based on app/token configuration | + +With OAuth, the remote server can dynamically request additional scopes as needed. With PATs, scopes are fixed at token creation, so the server proactively hides tools you can't use. + +## OAuth Scope Challenges (Remote Server) + +When using the [remote MCP server](./remote-server.md) with OAuth authentication, the server uses a different approach called **scope challenges**. Instead of hiding tools upfront, all tools are available, and the server requests additional scopes on-demand when you try to use a tool that requires them. + +**How it works:** +1. You attempt to use a tool (e.g., creating an issue) +2. If your current OAuth token lacks the required scope, the server returns an OAuth scope challenge +3. Your MCP client prompts you to authorize the additional scope +4. After authorization, the operation completes successfully + +This provides a smoother user experience for OAuth users since you only grant permissions as needed, rather than requesting all scopes upfront. + +## Checking Your Token's Scopes + +To see what scopes your token has, you can run: + +```bash +curl -sI -H "Authorization: Bearer $GITHUB_PERSONAL_ACCESS_TOKEN" \ + https://api.github.com/user | grep -i x-oauth-scopes +``` + +Example output: +``` +x-oauth-scopes: delete_repo, gist, read:org, repo +``` + +## Scope Hierarchy + +Some scopes implicitly include others: + +- `repo` → includes `public_repo`, `security_events` +- `admin:org` → includes `write:org` → includes `read:org` +- `project` → includes `read:project` + +This means if your token has `repo`, tools requiring `security_events` will also be available. + +Each tool in the [README](../README.md#tools) lists its required and accepted OAuth scopes. + +## Public Repository Access + +Read-only tools that only require `repo` or `public_repo` scopes are **always visible**, even if your token doesn't have these scopes. This is because these tools work on public repositories without authentication. + +For example, `get_file_contents` is always available—you can read files from any public repository regardless of your token's scopes. However, write operations like `create_or_update_file` will be hidden if your token lacks `repo` scope. + +> **Note:** The GitHub API doesn't return `public_repo` in the `X-OAuth-Scopes` header—it's implicit. The server handles this by not filtering read-only repository tools. + +## Graceful Degradation + +If the server cannot fetch your token's scopes (e.g., network issues, rate limiting), it logs a warning and continues **without filtering**. This ensures the server remains usable even when scope detection fails. + +``` +WARN: failed to fetch token scopes, continuing without scope filtering +``` + +## Classic vs Fine-Grained Personal Access Tokens + +**Classic PATs** (`ghp_` prefix) support OAuth scopes and return them in the `X-OAuth-Scopes` header. Scope filtering works fully with these tokens. + +**Fine-grained PATs** (`github_pat_` prefix) use a different permission model based on repository access and specific permissions rather than OAuth scopes. They don't return the `X-OAuth-Scopes` header, so scope filtering is skipped. All tools will be available, but the GitHub API will still enforce permissions at the API level—you'll get errors if you try to use tools your token doesn't have permission for. + +## GitHub App and Server-to-Server Tokens + +**GitHub App installation tokens** (`ghs_` prefix) and other server-to-server tokens use a permission model based on the app's installation permissions rather than OAuth scopes. These tokens don't return the `X-OAuth-Scopes` header, so scope filtering is skipped. The GitHub API enforces permissions based on the app's configuration. + +## Troubleshooting + +| Problem | Cause | Solution | +|---------|-------|----------| +| Missing expected tools | Token lacks required scope | [Edit your PAT's scopes](https://github.com/settings/tokens) in GitHub settings | +| All tools visible despite limited PAT | Scope detection failed | Check logs for warnings about scope fetching | +| "Insufficient permissions" errors | Tool visible but scope insufficient | This shouldn't happen with scope filtering; report as bug | + +> **Tip:** You can adjust the scopes of an existing classic PAT at any time via [GitHub's token settings](https://github.com/settings/tokens). After updating scopes, restart the MCP server to pick up the changes. + +## Related Documentation + +- [Server Configuration Guide](./server-configuration.md) +- [GitHub PAT Documentation](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens) +- [OAuth Scopes Reference](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/scopes-for-oauth-apps) diff --git a/docs/server-configuration.md b/docs/server-configuration.md index e8b7637bd..46ec3bc64 100644 --- a/docs/server-configuration.md +++ b/docs/server-configuration.md @@ -12,6 +12,7 @@ We currently support the following ways in which the GitHub MCP Server can be co | Read-Only Mode | `X-MCP-Readonly` header or `/readonly` URL | `--read-only` flag or `GITHUB_READ_ONLY` env var | | Dynamic Mode | Not available | `--dynamic-toolsets` flag or `GITHUB_DYNAMIC_TOOLSETS` env var | | Lockdown Mode | `X-MCP-Lockdown` header | `--lockdown-mode` flag or `GITHUB_LOCKDOWN_MODE` env var | +| Scope Filtering | Always enabled | Always enabled | > **Default behavior:** If you don't specify any configuration, the server uses the **default toolsets**: `context`, `issues`, `pull_requests`, `repos`, `users`. @@ -330,6 +331,20 @@ Lockdown mode ensures the server only surfaces content in public repositories fr --- +### Scope Filtering + +**Automatic feature:** The server handles OAuth scopes differently depending on authentication type: + +- **Classic PATs** (`ghp_` prefix): Tools are filtered at startup based on token scopes—you only see tools you have permission to use +- **OAuth** (remote server): Uses scope challenges—when a tool needs a scope you haven't granted, you're prompted to authorize it +- **Other tokens**: No filtering—all tools shown, API enforces permissions + +This happens transparently—no configuration needed. If scope detection fails for a classic PAT (e.g., network issues), the server logs a warning and continues with all tools available. + +See [Scope Filtering](./scope-filtering.md) for details on how filtering works with different token types. + +--- + ## Troubleshooting | Problem | Cause | Solution | diff --git a/docs/testing.md b/docs/testing.md index 226660e9d..2186b564b 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -7,7 +7,7 @@ This project uses a combination of unit tests and end-to-end (e2e) tests to ensu - Unit tests are located alongside implementation, with filenames ending in `_test.go`. - Currently the preference is to use internal tests i.e. test files do not have `_test` package suffix. - Tests use [testify](https://github.com/stretchr/testify) for assertions and require statements. Use `require` when continuing the test is not meaningful, for example it is almost never correct to continue after an error expectation. -- Mocking is performed using [go-github-mock](https://github.com/migueleliasweb/go-github-mock) or `githubv4mock` for simulating GitHub rest and GQL API responses. +- REST mocking is performed with the in-repo `MockHTTPClientWithHandlers` helpers; GraphQL mocking uses `githubv4mock`. - Each tool's schema is snapshotted and checked for changes using the `toolsnaps` utility (see below). - Tests are designed to be explicit and verbose to aid maintainability and clarity. - Handler unit tests should take the form of: diff --git a/docs/tool-renaming.md b/docs/tool-renaming.md index cf342f6dc..050ac9b77 100644 --- a/docs/tool-renaming.md +++ b/docs/tool-renaming.md @@ -46,15 +46,23 @@ Will get `issue_read` and `get_file_contents` tools registered, with no errors. | Old Name | New Name | |----------|----------| +| `add_project_item` | `projects_write` | | `cancel_workflow_run` | `actions_run_trigger` | +| `delete_project_item` | `projects_write` | | `delete_workflow_run_logs` | `actions_run_trigger` | | `download_workflow_run_artifact` | `actions_get` | +| `get_project` | `projects_get` | +| `get_project_field` | `projects_get` | +| `get_project_item` | `projects_get` | | `get_workflow` | `actions_get` | | `get_workflow_job` | `actions_get` | | `get_workflow_job_logs` | `actions_get` | | `get_workflow_run` | `actions_get` | | `get_workflow_run_logs` | `actions_get` | | `get_workflow_run_usage` | `actions_get` | +| `list_project_fields` | `projects_list` | +| `list_project_items` | `projects_list` | +| `list_projects` | `projects_list` | | `list_workflow_jobs` | `actions_list` | | `list_workflow_run_artifacts` | `actions_list` | | `list_workflow_runs` | `actions_list` | @@ -62,4 +70,5 @@ Will get `issue_read` and `get_file_contents` tools registered, with no errors. | `rerun_failed_jobs` | `actions_run_trigger` | | `rerun_workflow_run` | `actions_run_trigger` | | `run_workflow` | `actions_run_trigger` | +| `update_project_item` | `projects_write` | diff --git a/go.mod b/go.mod index 691a949bd..5322b47ec 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,6 @@ require ( github.com/google/jsonschema-go v0.4.2 github.com/josephburnett/jd v1.9.2 github.com/microcosm-cc/bluemonday v1.0.27 - github.com/migueleliasweb/go-github-mock v1.3.0 github.com/muesli/cache2go v0.0.0-20221011235721-518229cd8021 github.com/spf13/cobra v1.10.2 github.com/spf13/viper v1.21.0 @@ -18,9 +17,7 @@ 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/google/go-github/v71 v71.0.0 // indirect github.com/gorilla/css v1.0.1 // indirect - github.com/gorilla/mux v1.8.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/stretchr/objx v0.5.2 // indirect @@ -53,7 +50,6 @@ require ( golang.org/x/oauth2 v0.30.0 // indirect golang.org/x/sys v0.31.0 // indirect golang.org/x/text v0.28.0 // indirect - golang.org/x/time v0.5.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 6f38bea2f..25cbf7fa9 100644 --- a/go.sum +++ b/go.sum @@ -22,8 +22,6 @@ github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVI github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/go-github/v71 v71.0.0 h1:Zi16OymGKZZMm8ZliffVVJ/Q9YZreDKONCr+WUd0Z30= -github.com/google/go-github/v71 v71.0.0/go.mod h1:URZXObp2BLlMjwu0O8g4y6VBneUj2bCHgnI8FfgZ51M= github.com/google/go-github/v79 v79.0.0 h1:MdodQojuFPBhmtwHiBcIGLw/e/wei2PvFX9ndxK0X4Y= github.com/google/go-github/v79 v79.0.0/go.mod h1:OAFbNhq7fQwohojb06iIIQAB9CBGYLq999myfUFnrS4= github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= @@ -32,8 +30,6 @@ github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbc github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= -github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= -github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/josephburnett/jd v1.9.2 h1:ECJRRFXCCqbtidkAHckHGSZm/JIaAxS1gygHLF8MI5Y= @@ -55,8 +51,6 @@ github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0 github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= 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/migueleliasweb/go-github-mock v1.3.0 h1:2sVP9JEMB2ubQw1IKto3/fzF51oFC6eVWOOFDgQoq88= -github.com/migueleliasweb/go-github-mock v1.3.0/go.mod h1:ipQhV8fTcj/G6m7BKzin08GaJ/3B5/SonRAkgrk0zCY= github.com/modelcontextprotocol/go-sdk v1.2.0 h1:Y23co09300CEk8iZ/tMxIX1dVmKZkzoSBZOpJwUnc/s= github.com/modelcontextprotocol/go-sdk v1.2.0/go.mod h1:6fM3LCm3yV7pAs8isnKLn07oKtB0MP9LHd3DfAcKw10= github.com/muesli/cache2go v0.0.0-20221011235721-518229cd8021 h1:31Y+Yu373ymebRdJN1cWLLooHH8xAr0MhKTEJGV/87g= @@ -114,8 +108,6 @@ golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= -golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= -golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0= golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/internal/ghmcp/server.go b/internal/ghmcp/server.go index 9859e2e9b..250f6b4cc 100644 --- a/internal/ghmcp/server.go +++ b/internal/ghmcp/server.go @@ -19,6 +19,7 @@ import ( "github.com/github/github-mcp-server/pkg/lockdown" mcplog "github.com/github/github-mcp-server/pkg/log" "github.com/github/github-mcp-server/pkg/raw" + "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" gogithub "github.com/google/go-github/v79/github" "github.com/modelcontextprotocol/go-sdk/mcp" @@ -67,6 +68,11 @@ type MCPServerConfig struct { Logger *slog.Logger // RepoAccessTTL overrides the default TTL for repository access cache entries. RepoAccessTTL *time.Duration + + // TokenScopes contains the OAuth scopes available to the token. + // When non-nil, tools requiring scopes not in this list will be hidden. + // This is used for PAT scope filtering where we can't issue scope challenges. + TokenScopes []string } // githubClients holds all the GitHub API clients created for a server instance. @@ -211,13 +217,19 @@ func NewMCPServer(cfg MCPServerConfig) (*mcp.Server, error) { }) // Build and register the tool/resource/prompt inventory - inventory := github.NewInventory(cfg.Translator). + inventoryBuilder := github.NewInventory(cfg.Translator). WithDeprecatedAliases(github.DeprecatedToolAliases). WithReadOnly(cfg.ReadOnly). WithToolsets(enabledToolsets). WithTools(github.CleanTools(cfg.EnabledTools)). - WithFeatureChecker(createFeatureChecker(cfg.EnabledFeatures)). - Build() + WithFeatureChecker(createFeatureChecker(cfg.EnabledFeatures)) + + // Apply token scope filtering if scopes are known (for PAT filtering) + if cfg.TokenScopes != nil { + inventoryBuilder = inventoryBuilder.WithFilter(github.CreateToolScopeFilter(cfg.TokenScopes)) + } + + inventory := inventoryBuilder.Build() if unrecognized := inventory.UnrecognizedToolsets(); len(unrecognized) > 0 { fmt.Fprintf(os.Stderr, "Warning: unrecognized toolsets ignored: %s\n", strings.Join(unrecognized, ", ")) @@ -338,6 +350,22 @@ func RunStdioServer(cfg StdioServerConfig) error { logger := slog.New(slogHandler) logger.Info("starting server", "version", cfg.Version, "host", cfg.Host, "dynamicToolsets", cfg.DynamicToolsets, "readOnly", cfg.ReadOnly, "lockdownEnabled", cfg.LockdownMode) + // Fetch token scopes for scope-based tool filtering (PAT tokens only) + // Only classic PATs (ghp_ prefix) return OAuth scopes via X-OAuth-Scopes header. + // Fine-grained PATs and other token types don't support this, so we skip filtering. + var tokenScopes []string + if strings.HasPrefix(cfg.Token, "ghp_") { + fetchedScopes, err := fetchTokenScopesForHost(ctx, cfg.Token, cfg.Host) + if err != nil { + logger.Warn("failed to fetch token scopes, continuing without scope filtering", "error", err) + } else { + tokenScopes = fetchedScopes + logger.Info("token scopes fetched for filtering", "scopes", tokenScopes) + } + } else { + logger.Debug("skipping scope filtering for non-PAT token") + } + ghServer, err := NewMCPServer(MCPServerConfig{ Version: cfg.Version, Host: cfg.Host, @@ -352,6 +380,7 @@ func RunStdioServer(cfg StdioServerConfig) error { LockdownMode: cfg.LockdownMode, Logger: logger, RepoAccessTTL: cfg.RepoAccessCacheTTL, + TokenScopes: tokenScopes, }) if err != nil { return fmt.Errorf("failed to create MCP server: %w", err) @@ -593,6 +622,12 @@ type bearerAuthTransport struct { func (t *bearerAuthTransport) RoundTrip(req *http.Request) (*http.Response, error) { req = req.Clone(req.Context()) req.Header.Set("Authorization", "Bearer "+t.token) + + // Check for GraphQL-Features in context and add header if present + if features := github.GetGraphQLFeatures(req.Context()); len(features) > 0 { + req.Header.Set("GraphQL-Features", strings.Join(features, ", ")) + } + return t.transport.RoundTrip(req) } @@ -636,3 +671,18 @@ func addUserAgentsMiddleware(cfg MCPServerConfig, restClient *gogithub.Client, g } } } + +// fetchTokenScopesForHost fetches the OAuth scopes for a token from the GitHub API. +// It constructs the appropriate API host URL based on the configured host. +func fetchTokenScopesForHost(ctx context.Context, token, host string) ([]string, error) { + apiHost, err := parseAPIHost(host) + if err != nil { + return nil, fmt.Errorf("failed to parse API host: %w", err) + } + + fetcher := scopes.NewFetcher(scopes.FetcherOptions{ + APIHost: apiHost.baseRESTURL.String(), + }) + + return fetcher.FetchTokenScopes(ctx, token) +} diff --git a/jons b/jons new file mode 100644 index 000000000..7ee3a4beb --- /dev/null +++ b/jons @@ -0,0 +1 @@ +ghkl \ No newline at end of file diff --git a/pkg/errors/error.go b/pkg/errors/error.go index d17fedd92..93ea852a8 100644 --- a/pkg/errors/error.go +++ b/pkg/errors/error.go @@ -120,6 +120,14 @@ func NewGitHubAPIErrorToCtx(ctx context.Context, message string, resp *github.Re return ctx, nil } +func NewGitHubGraphQLErrorToCtx(ctx context.Context, message string, err error) (context.Context, error) { + graphQLErr := newGitHubGraphQLError(message, err) + if ctx != nil { + _, _ = addGitHubGraphQLErrorToContext(ctx, graphQLErr) // Explicitly ignore error for graceful handling + } + return ctx, nil +} + func addGitHubAPIErrorToContext(ctx context.Context, err *GitHubAPIError) (context.Context, error) { if val, ok := ctx.Value(GitHubErrorKey{}).(*GitHubCtxErrors); ok { val.api = append(val.api, err) // append the error to the existing slice in the context diff --git a/pkg/github/__toolsnaps__/actions_list.snap b/pkg/github/__toolsnaps__/actions_list.snap index 3968a6eae..4bd029388 100644 --- a/pkg/github/__toolsnaps__/actions_list.snap +++ b/pkg/github/__toolsnaps__/actions_list.snap @@ -6,11 +6,6 @@ "description": "Tools for listing GitHub Actions resources.\nUse this tool to list workflows in a repository, or list workflow runs, jobs, and artifacts for a specific workflow or workflow run.\n", "inputSchema": { "type": "object", - "required": [ - "method", - "owner", - "repo" - ], "properties": { "method": { "type": "string", @@ -43,11 +38,10 @@ }, "resource_id": { "type": "string", - "description": "The unique identifier of the resource. This will vary based on the \"method\" provided, so ensure you provide the correct ID:\n- Do not provide any resource ID for 'list_workflows' method.\n- Provide a workflow ID or workflow file name (e.g. ci.yaml) for 'list_workflow_runs' method.\n- Provide a workflow run ID for 'list_workflow_jobs' and 'list_workflow_run_artifacts' methods.\n" + "description": "The unique identifier of the resource. This will vary based on the \"method\" provided, so ensure you provide the correct ID:\n- Do not provide any resource ID for 'list_workflows' method.\n- 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.\n- Provide a workflow run ID for 'list_workflow_jobs' and 'list_workflow_run_artifacts' methods.\n" }, "workflow_jobs_filter": { "type": "object", - "description": "Filters for workflow jobs. **ONLY** used when method is 'list_workflow_jobs'", "properties": { "filter": { "type": "string", @@ -57,11 +51,11 @@ "all" ] } - } + }, + "description": "Filters for workflow jobs. **ONLY** used when method is 'list_workflow_jobs'" }, "workflow_runs_filter": { "type": "object", - "description": "Filters for workflow runs. **ONLY** used when method is 'list_workflow_runs'", "properties": { "actor": { "type": "string", @@ -120,9 +114,15 @@ "waiting" ] } - } + }, + "description": "Filters for workflow runs. **ONLY** used when method is 'list_workflow_runs'" } - } + }, + "required": [ + "method", + "owner", + "repo" + ] }, "name": "actions_list" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/assign_copilot_to_issue.snap b/pkg/github/__toolsnaps__/assign_copilot_to_issue.snap index 22c380055..7ad1922a0 100644 --- a/pkg/github/__toolsnaps__/assign_copilot_to_issue.snap +++ b/pkg/github/__toolsnaps__/assign_copilot_to_issue.snap @@ -6,13 +6,12 @@ "description": "Assign Copilot to a specific issue in a GitHub repository.\n\nThis tool can help with the following outcomes:\n- a Pull Request created with source code changes to resolve the issue\n\n\nMore information can be found at:\n- https://docs.github.com/en/copilot/using-github-copilot/using-copilot-coding-agent-to-work-on-tasks/about-assigning-tasks-to-copilot\n", "inputSchema": { "type": "object", - "required": [ - "owner", - "repo", - "issueNumber" - ], "properties": { - "issueNumber": { + "base_ref": { + "type": "string", + "description": "Git reference (e.g., branch) that the agent will start its work from. If not specified, defaults to the repository's default branch" + }, + "issue_number": { "type": "number", "description": "Issue number" }, @@ -24,7 +23,12 @@ "type": "string", "description": "Repository name" } - } + }, + "required": [ + "owner", + "repo", + "issue_number" + ] }, "name": "assign_copilot_to_issue", "icons": [ diff --git a/pkg/github/__toolsnaps__/projects_get.snap b/pkg/github/__toolsnaps__/projects_get.snap new file mode 100644 index 000000000..9758de0f2 --- /dev/null +++ b/pkg/github/__toolsnaps__/projects_get.snap @@ -0,0 +1,59 @@ +{ + "annotations": { + "readOnlyHint": true, + "title": "Get details of GitHub Projects resources" + }, + "description": "Get details about specific GitHub Projects resources.\nUse this tool to get details about individual projects, project fields, and project items by their unique IDs.\n", + "inputSchema": { + "type": "object", + "required": [ + "method", + "owner_type", + "owner", + "project_number" + ], + "properties": { + "field_id": { + "type": "number", + "description": "The field's ID. Required for 'get_project_field' method." + }, + "fields": { + "type": "array", + "description": "Specific list of field IDs to include in the response when getting a project item (e.g. [\"102589\", \"985201\", \"169875\"]). If not provided, only the title field is included. Only used for 'get_project_item' method.", + "items": { + "type": "string" + } + }, + "item_id": { + "type": "number", + "description": "The item's ID. Required for 'get_project_item' method." + }, + "method": { + "type": "string", + "description": "The method to execute", + "enum": [ + "get_project", + "get_project_field", + "get_project_item" + ] + }, + "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." + }, + "owner_type": { + "type": "string", + "description": "Owner type", + "enum": [ + "user", + "org" + ] + }, + "project_number": { + "type": "number", + "description": "The project's number." + } + } + }, + "name": "projects_get" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/projects_list.snap b/pkg/github/__toolsnaps__/projects_list.snap new file mode 100644 index 000000000..7cc2e2df7 --- /dev/null +++ b/pkg/github/__toolsnaps__/projects_list.snap @@ -0,0 +1,66 @@ +{ + "annotations": { + "readOnlyHint": true, + "title": "List GitHub Projects resources" + }, + "description": "Tools for listing GitHub Projects resources.\nUse this tool to list projects for a user or organization, or list project fields and items for a specific project.\n", + "inputSchema": { + "type": "object", + "required": [ + "method", + "owner_type", + "owner" + ], + "properties": { + "after": { + "type": "string", + "description": "Forward pagination cursor from previous pageInfo.nextCursor." + }, + "before": { + "type": "string", + "description": "Backward pagination cursor from previous pageInfo.prevCursor (rare)." + }, + "fields": { + "type": "array", + "description": "Field IDs to include when listing project items (e.g. [\"102589\", \"985201\"]). CRITICAL: Always provide to get field values. Without this, only titles returned. Only used for 'list_project_items' method.", + "items": { + "type": "string" + } + }, + "method": { + "type": "string", + "description": "The action to perform", + "enum": [ + "list_projects", + "list_project_fields", + "list_project_items" + ] + }, + "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." + }, + "owner_type": { + "type": "string", + "description": "Owner type", + "enum": [ + "user", + "org" + ] + }, + "per_page": { + "type": "number", + "description": "Results per page (max 50)" + }, + "project_number": { + "type": "number", + "description": "The project's number. Required for 'list_project_fields' and 'list_project_items' methods." + }, + "query": { + "type": "string", + "description": "Filter/query string. For list_projects: filter by title text and state (e.g. \"roadmap is:open\"). For list_project_items: advanced filtering using GitHub's project filtering syntax." + } + } + }, + "name": "projects_list" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/projects_write.snap b/pkg/github/__toolsnaps__/projects_write.snap new file mode 100644 index 000000000..2224590c5 --- /dev/null +++ b/pkg/github/__toolsnaps__/projects_write.snap @@ -0,0 +1,60 @@ +{ + "annotations": { + "destructiveHint": true, + "title": "Modify GitHub Project items" + }, + "description": "Add, update, or delete project items in a GitHub Project.", + "inputSchema": { + "type": "object", + "required": [ + "method", + "owner_type", + "owner", + "project_number" + ], + "properties": { + "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." + }, + "item_type": { + "type": "string", + "description": "The item's type, either issue or pull_request. Required for 'add_project_item' method.", + "enum": [ + "issue", + "pull_request" + ] + }, + "method": { + "type": "string", + "description": "The method to execute", + "enum": [ + "add_project_item", + "update_project_item", + "delete_project_item" + ] + }, + "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." + }, + "owner_type": { + "type": "string", + "description": "Owner type", + "enum": [ + "user", + "org" + ] + }, + "project_number": { + "type": "number", + "description": "The project's 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." + } + } + }, + "name": "projects_write" +} \ No newline at end of file diff --git a/pkg/github/actions.go b/pkg/github/actions.go index 6c7cdc367..14cb8028c 100644 --- a/pkg/github/actions.go +++ b/pkg/github/actions.go @@ -13,6 +13,7 @@ import ( buffer "github.com/github/github-mcp-server/pkg/buffer" ghErrors "github.com/github/github-mcp-server/pkg/errors" "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" "github.com/google/go-github/v79/github" @@ -74,6 +75,7 @@ func ListWorkflows(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"owner", "repo"}, }), }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { client, err := deps.GetClient(ctx) if err != nil { @@ -200,6 +202,7 @@ func ListWorkflowRuns(t translations.TranslationHelperFunc) inventory.ServerTool Required: []string{"owner", "repo", "workflow_id"}, }), }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { client, err := deps.GetClient(ctx) if err != nil { @@ -311,6 +314,7 @@ func RunWorkflow(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"owner", "repo", "workflow_id", "ref"}, }, }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { client, err := deps.GetClient(ctx) if err != nil { @@ -415,6 +419,7 @@ func GetWorkflowRun(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"owner", "repo", "run_id"}, }, }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { client, err := deps.GetClient(ctx) if err != nil { @@ -483,6 +488,7 @@ func GetWorkflowRunLogs(t translations.TranslationHelperFunc) inventory.ServerTo Required: []string{"owner", "repo", "run_id"}, }, }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { client, err := deps.GetClient(ctx) if err != nil { @@ -566,6 +572,7 @@ func ListWorkflowJobs(t translations.TranslationHelperFunc) inventory.ServerTool Required: []string{"owner", "repo", "run_id"}, }), }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { client, err := deps.GetClient(ctx) if err != nil { @@ -678,6 +685,7 @@ func GetJobLogs(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"owner", "repo"}, }, }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { client, err := deps.GetClient(ctx) if err != nil { @@ -926,6 +934,7 @@ func RerunWorkflowRun(t translations.TranslationHelperFunc) inventory.ServerTool Required: []string{"owner", "repo", "run_id"}, }, }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { client, err := deps.GetClient(ctx) if err != nil { @@ -1001,6 +1010,7 @@ func RerunFailedJobs(t translations.TranslationHelperFunc) inventory.ServerTool Required: []string{"owner", "repo", "run_id"}, }, }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { client, err := deps.GetClient(ctx) if err != nil { @@ -1076,6 +1086,7 @@ func CancelWorkflowRun(t translations.TranslationHelperFunc) inventory.ServerToo Required: []string{"owner", "repo", "run_id"}, }, }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { client, err := deps.GetClient(ctx) if err != nil { @@ -1153,6 +1164,7 @@ func ListWorkflowRunArtifacts(t translations.TranslationHelperFunc) inventory.Se Required: []string{"owner", "repo", "run_id"}, }), }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { client, err := deps.GetClient(ctx) if err != nil { @@ -1233,6 +1245,7 @@ func DownloadWorkflowRunArtifact(t translations.TranslationHelperFunc) inventory Required: []string{"owner", "repo", "artifact_id"}, }, }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { client, err := deps.GetClient(ctx) if err != nil { @@ -1311,6 +1324,7 @@ func DeleteWorkflowRunLogs(t translations.TranslationHelperFunc) inventory.Serve Required: []string{"owner", "repo", "run_id"}, }, }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { client, err := deps.GetClient(ctx) if err != nil { @@ -1386,6 +1400,7 @@ func GetWorkflowRunUsage(t translations.TranslationHelperFunc) inventory.ServerT Required: []string{"owner", "repo", "run_id"}, }, }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { client, err := deps.GetClient(ctx) if err != nil { @@ -1463,7 +1478,7 @@ Use this tool to list workflows in a repository, or list workflow runs, jobs, an Type: "string", Description: `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. +- 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. `, }, @@ -1550,6 +1565,7 @@ Use this tool to list workflows in a repository, or list workflow runs, jobs, an Required: []string{"method", "owner", "repo"}, }, }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { @@ -1586,18 +1602,18 @@ Use this tool to list workflows in a repository, or list workflow runs, jobs, an switch method { case actionsMethodListWorkflows: // Do nothing, no resource ID needed + case actionsMethodListWorkflowRuns: + // resource_id is optional for list_workflow_runs + // If not provided, list all workflow runs in the repository default: if resourceID == "" { return utils.NewToolResultError(fmt.Sprintf("missing required parameter for method %s: resource_id", method)), nil, nil } - // For list_workflow_runs, resource_id could be a filename or numeric ID - // For other actions, resource ID must be an integer - if method != actionsMethodListWorkflowRuns { - resourceIDInt, parseErr = strconv.ParseInt(resourceID, 10, 64) - if parseErr != nil { - return utils.NewToolResultError(fmt.Sprintf("invalid resource_id, must be an integer for method %s: %v", method, parseErr)), nil, nil - } + // resource ID must be an integer for jobs and artifacts + resourceIDInt, parseErr = strconv.ParseInt(resourceID, 10, 64) + if parseErr != nil { + return utils.NewToolResultError(fmt.Sprintf("invalid resource_id, must be an integer for method %s: %v", method, parseErr)), nil, nil } } @@ -1668,6 +1684,7 @@ Use this tool to get details about individual workflows, workflow runs, jobs, an Required: []string{"method", "owner", "repo", "resource_id"}, }, }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { @@ -1781,6 +1798,7 @@ func ActionsRunTrigger(t translations.TranslationHelperFunc) inventory.ServerToo Required: []string{"method", "owner", "repo"}, }, }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { @@ -1895,6 +1913,7 @@ For single job logs, provide job_id. For all failed jobs in a run, provide run_i Required: []string{"owner", "repo"}, }, }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { @@ -2063,7 +2082,9 @@ func listWorkflowRuns(ctx context.Context, client *github.Client, args map[strin var workflowRuns *github.WorkflowRuns var resp *github.Response - if workflowIDInt, parseErr := strconv.ParseInt(resourceID, 10, 64); parseErr == nil { + if resourceID == "" { + workflowRuns, resp, err = client.Actions.ListRepositoryWorkflowRuns(ctx, owner, repo, listWorkflowRunsOptions) + } else if workflowIDInt, parseErr := strconv.ParseInt(resourceID, 10, 64); parseErr == nil { workflowRuns, resp, err = client.Actions.ListWorkflowRunsByID(ctx, owner, repo, workflowIDInt, listWorkflowRunsOptions) } else { workflowRuns, resp, err = client.Actions.ListWorkflowRunsByFileName(ctx, owner, repo, resourceID, listWorkflowRunsOptions) diff --git a/pkg/github/actions_test.go b/pkg/github/actions_test.go index f2d336e21..0d47236f6 100644 --- a/pkg/github/actions_test.go +++ b/pkg/github/actions_test.go @@ -18,7 +18,6 @@ import ( "github.com/github/github-mcp-server/pkg/translations" "github.com/google/go-github/v79/github" "github.com/google/jsonschema-go/jsonschema" - "github.com/migueleliasweb/go-github-mock/src/mock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -1394,17 +1393,11 @@ func Test_RerunFailedJobs(t *testing.T) { }{ { name: "successful rerun of failed jobs", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{ - Pattern: "/repos/owner/repo/actions/runs/12345/rerun-failed-jobs", - Method: "POST", - }, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusCreated) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposActionsRunsRerunFailedJobsByOwnerByRepoByRunID: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusCreated) + }), + }), requestArgs: map[string]any{ "owner": "owner", "repo": "repo", @@ -1414,7 +1407,7 @@ func Test_RerunFailedJobs(t *testing.T) { }, { name: "missing required parameter run_id", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]any{ "owner": "owner", "repo": "repo", @@ -1466,17 +1459,11 @@ func Test_RerunWorkflowRun_Behavioral(t *testing.T) { }{ { name: "successful rerun of workflow run", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{ - Pattern: "/repos/owner/repo/actions/runs/12345/rerun", - Method: "POST", - }, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusCreated) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposActionsRunsRerunByOwnerByRepoByRunID: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusCreated) + }), + }), requestArgs: map[string]any{ "owner": "owner", "repo": "repo", @@ -1486,7 +1473,7 @@ func Test_RerunWorkflowRun_Behavioral(t *testing.T) { }, { name: "missing required parameter run_id", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]any{ "owner": "owner", "repo": "repo", @@ -1538,32 +1525,29 @@ func Test_ListWorkflowRuns_Behavioral(t *testing.T) { }{ { name: "successful workflow runs listing", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposActionsWorkflowsRunsByOwnerByRepoByWorkflowId, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - runs := &github.WorkflowRuns{ - TotalCount: github.Ptr(2), - WorkflowRuns: []*github.WorkflowRun{ - { - ID: github.Ptr(int64(123)), - Name: github.Ptr("CI"), - Status: github.Ptr("completed"), - Conclusion: github.Ptr("success"), - }, - { - ID: github.Ptr(int64(456)), - Name: github.Ptr("CI"), - Status: github.Ptr("completed"), - Conclusion: github.Ptr("failure"), - }, + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposActionsWorkflowsRunsByOwnerByRepoByWorkflowID: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + runs := &github.WorkflowRuns{ + TotalCount: github.Ptr(2), + WorkflowRuns: []*github.WorkflowRun{ + { + ID: github.Ptr(int64(123)), + Name: github.Ptr("CI"), + Status: github.Ptr("completed"), + Conclusion: github.Ptr("success"), }, - } - w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(runs) - }), - ), - ), + { + ID: github.Ptr(int64(456)), + Name: github.Ptr("CI"), + Status: github.Ptr("completed"), + Conclusion: github.Ptr("failure"), + }, + }, + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(runs) + }), + }), requestArgs: map[string]any{ "owner": "owner", "repo": "repo", @@ -1573,7 +1557,7 @@ func Test_ListWorkflowRuns_Behavioral(t *testing.T) { }, { name: "missing required parameter workflow_id", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]any{ "owner": "owner", "repo": "repo", @@ -1625,21 +1609,18 @@ func Test_GetWorkflowRun_Behavioral(t *testing.T) { }{ { name: "successful get workflow run", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposActionsRunsByOwnerByRepoByRunId, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - run := &github.WorkflowRun{ - ID: github.Ptr(int64(12345)), - Name: github.Ptr("CI"), - Status: github.Ptr("completed"), - Conclusion: github.Ptr("success"), - } - w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(run) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposActionsRunsByOwnerByRepoByRunID: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + run := &github.WorkflowRun{ + ID: github.Ptr(int64(12345)), + Name: github.Ptr("CI"), + Status: github.Ptr("completed"), + Conclusion: github.Ptr("success"), + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(run) + }), + }), requestArgs: map[string]any{ "owner": "owner", "repo": "repo", @@ -1649,7 +1630,7 @@ func Test_GetWorkflowRun_Behavioral(t *testing.T) { }, { name: "missing required parameter run_id", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]any{ "owner": "owner", "repo": "repo", @@ -1701,15 +1682,12 @@ func Test_GetWorkflowRunLogs_Behavioral(t *testing.T) { }{ { name: "successful get workflow run logs", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposActionsRunsLogsByOwnerByRepoByRunId, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Location", "https://github.com/logs/run/12345") - w.WriteHeader(http.StatusFound) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposActionsRunsLogsByOwnerByRepoByRunID: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Location", "https://github.com/logs/run/12345") + w.WriteHeader(http.StatusFound) + }), + }), requestArgs: map[string]any{ "owner": "owner", "repo": "repo", @@ -1719,7 +1697,7 @@ func Test_GetWorkflowRunLogs_Behavioral(t *testing.T) { }, { name: "missing required parameter run_id", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]any{ "owner": "owner", "repo": "repo", @@ -1771,32 +1749,29 @@ func Test_ListWorkflowJobs_Behavioral(t *testing.T) { }{ { name: "successful list workflow jobs", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposActionsRunsJobsByOwnerByRepoByRunId, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - jobs := &github.Jobs{ - TotalCount: github.Ptr(2), - Jobs: []*github.WorkflowJob{ - { - ID: github.Ptr(int64(1)), - Name: github.Ptr("build"), - Status: github.Ptr("completed"), - Conclusion: github.Ptr("success"), - }, - { - ID: github.Ptr(int64(2)), - Name: github.Ptr("test"), - Status: github.Ptr("completed"), - Conclusion: github.Ptr("failure"), - }, + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposActionsRunsJobsByOwnerByRepoByRunID: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + jobs := &github.Jobs{ + TotalCount: github.Ptr(2), + Jobs: []*github.WorkflowJob{ + { + ID: github.Ptr(int64(1)), + Name: github.Ptr("build"), + Status: github.Ptr("completed"), + Conclusion: github.Ptr("success"), }, - } - w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(jobs) - }), - ), - ), + { + ID: github.Ptr(int64(2)), + Name: github.Ptr("test"), + Status: github.Ptr("completed"), + Conclusion: github.Ptr("failure"), + }, + }, + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(jobs) + }), + }), requestArgs: map[string]any{ "owner": "owner", "repo": "repo", @@ -1806,7 +1781,7 @@ func Test_ListWorkflowJobs_Behavioral(t *testing.T) { }, { name: "missing required parameter run_id", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]any{ "owner": "owner", "repo": "repo", @@ -1873,32 +1848,29 @@ func Test_ActionsList_ListWorkflows(t *testing.T) { }{ { name: "successful workflow list", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposActionsWorkflowsByOwnerByRepo, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - workflows := &github.Workflows{ - TotalCount: github.Ptr(2), - Workflows: []*github.Workflow{ - { - ID: github.Ptr(int64(1)), - Name: github.Ptr("CI"), - Path: github.Ptr(".github/workflows/ci.yml"), - State: github.Ptr("active"), - }, - { - ID: github.Ptr(int64(2)), - Name: github.Ptr("Deploy"), - Path: github.Ptr(".github/workflows/deploy.yml"), - State: github.Ptr("active"), - }, + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposActionsWorkflowsByOwnerByRepo: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + workflows := &github.Workflows{ + TotalCount: github.Ptr(2), + Workflows: []*github.Workflow{ + { + ID: github.Ptr(int64(1)), + Name: github.Ptr("CI"), + Path: github.Ptr(".github/workflows/ci.yml"), + State: github.Ptr("active"), }, - } - w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(workflows) - }), - ), - ), + { + ID: github.Ptr(int64(2)), + Name: github.Ptr("Deploy"), + Path: github.Ptr(".github/workflows/deploy.yml"), + State: github.Ptr("active"), + }, + }, + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(workflows) + }), + }), requestArgs: map[string]any{ "method": "list_workflows", "owner": "owner", @@ -1908,7 +1880,7 @@ func Test_ActionsList_ListWorkflows(t *testing.T) { }, { name: "missing required parameter method", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]any{ "owner": "owner", "repo": "repo", @@ -1952,26 +1924,23 @@ func Test_ActionsList_ListWorkflowRuns(t *testing.T) { toolDef := ActionsList(translations.NullTranslationHelper) t.Run("successful workflow runs list", func(t *testing.T) { - mockedClient := mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposActionsWorkflowsRunsByOwnerByRepoByWorkflowId, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - runs := &github.WorkflowRuns{ - TotalCount: github.Ptr(1), - WorkflowRuns: []*github.WorkflowRun{ - { - ID: github.Ptr(int64(123)), - Name: github.Ptr("CI"), - Status: github.Ptr("completed"), - Conclusion: github.Ptr("success"), - }, + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposActionsWorkflowsRunsByOwnerByRepoByWorkflowID: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + runs := &github.WorkflowRuns{ + TotalCount: github.Ptr(1), + WorkflowRuns: []*github.WorkflowRun{ + { + ID: github.Ptr(int64(123)), + Name: github.Ptr("CI"), + Status: github.Ptr("completed"), + Conclusion: github.Ptr("success"), }, - } - w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(runs) - }), - ), - ) + }, + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(runs) + }), + }) client := github.NewClient(mockedClient) deps := BaseDeps{ @@ -1997,8 +1966,30 @@ func Test_ActionsList_ListWorkflowRuns(t *testing.T) { assert.NotNil(t, response.TotalCount) }) - t.Run("missing resource_id for list_workflow_runs", func(t *testing.T) { - mockedClient := mock.NewMockedHTTPClient() + t.Run("list all workflow runs without resource_id", func(t *testing.T) { + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposActionsRunsByOwnerByRepo: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + runs := &github.WorkflowRuns{ + TotalCount: github.Ptr(2), + WorkflowRuns: []*github.WorkflowRun{ + { + ID: github.Ptr(int64(123)), + Name: github.Ptr("CI"), + Status: github.Ptr("completed"), + Conclusion: github.Ptr("success"), + }, + { + ID: github.Ptr(int64(456)), + Name: github.Ptr("Deploy"), + Status: github.Ptr("in_progress"), + Conclusion: nil, + }, + }, + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(runs) + }), + }) client := github.NewClient(mockedClient) deps := BaseDeps{ @@ -2014,10 +2005,13 @@ func Test_ActionsList_ListWorkflowRuns(t *testing.T) { result, err := handler(ContextWithDeps(context.Background(), deps), &request) require.NoError(t, err) - require.True(t, result.IsError) + require.False(t, result.IsError) textContent := getTextResult(t, result) - assert.Contains(t, textContent.Text, "missing required parameter") + var response github.WorkflowRuns + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + assert.Equal(t, 2, *response.TotalCount) }) } @@ -2040,21 +2034,18 @@ func Test_ActionsGet_GetWorkflow(t *testing.T) { toolDef := ActionsGet(translations.NullTranslationHelper) t.Run("successful workflow get", func(t *testing.T) { - mockedClient := mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposActionsWorkflowsByOwnerByRepoByWorkflowId, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - workflow := &github.Workflow{ - ID: github.Ptr(int64(1)), - Name: github.Ptr("CI"), - Path: github.Ptr(".github/workflows/ci.yml"), - State: github.Ptr("active"), - } - w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(workflow) - }), - ), - ) + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposActionsWorkflowsByOwnerByRepoByWorkflowID: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + workflow := &github.Workflow{ + ID: github.Ptr(int64(1)), + Name: github.Ptr("CI"), + Path: github.Ptr(".github/workflows/ci.yml"), + State: github.Ptr("active"), + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(workflow) + }), + }) client := github.NewClient(mockedClient) deps := BaseDeps{ @@ -2086,21 +2077,18 @@ func Test_ActionsGet_GetWorkflowRun(t *testing.T) { toolDef := ActionsGet(translations.NullTranslationHelper) t.Run("successful workflow run get", func(t *testing.T) { - mockedClient := mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposActionsRunsByOwnerByRepoByRunId, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - run := &github.WorkflowRun{ - ID: github.Ptr(int64(12345)), - Name: github.Ptr("CI"), - Status: github.Ptr("completed"), - Conclusion: github.Ptr("success"), - } - w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(run) - }), - ), - ) + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposActionsRunsByOwnerByRepoByRunID: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + run := &github.WorkflowRun{ + ID: github.Ptr(int64(12345)), + Name: github.Ptr("CI"), + Status: github.Ptr("completed"), + Conclusion: github.Ptr("success"), + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(run) + }), + }) client := github.NewClient(mockedClient) deps := BaseDeps{ @@ -2157,14 +2145,11 @@ func Test_ActionsRunTrigger_RunWorkflow(t *testing.T) { }{ { name: "successful workflow run", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PostReposActionsWorkflowsDispatchesByOwnerByRepoByWorkflowId, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNoContent) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposActionsWorkflowsDispatchesByOwnerByRepoByWorkflowID: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + }), + }), requestArgs: map[string]any{ "method": "run_workflow", "owner": "owner", @@ -2176,7 +2161,7 @@ func Test_ActionsRunTrigger_RunWorkflow(t *testing.T) { }, { name: "missing required parameter workflow_id", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]any{ "method": "run_workflow", "owner": "owner", @@ -2188,7 +2173,7 @@ func Test_ActionsRunTrigger_RunWorkflow(t *testing.T) { }, { name: "missing required parameter ref", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]any{ "method": "run_workflow", "owner": "owner", @@ -2233,17 +2218,11 @@ func Test_ActionsRunTrigger_CancelWorkflowRun(t *testing.T) { toolDef := ActionsRunTrigger(translations.NullTranslationHelper) t.Run("successful workflow run cancellation", func(t *testing.T) { - mockedClient := mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{ - Pattern: "/repos/owner/repo/actions/runs/12345/cancel", - Method: "POST", - }, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusAccepted) - }), - ), - ) + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposActionsRunsCancelByOwnerByRepoByRunID: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusAccepted) + }), + }) client := github.NewClient(mockedClient) deps := BaseDeps{ @@ -2270,17 +2249,11 @@ func Test_ActionsRunTrigger_CancelWorkflowRun(t *testing.T) { }) t.Run("conflict when cancelling a workflow run", func(t *testing.T) { - mockedClient := mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{ - Pattern: "/repos/owner/repo/actions/runs/12345/cancel", - Method: "POST", - }, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusConflict) - }), - ), - ) + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposActionsRunsCancelByOwnerByRepoByRunID: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusConflict) + }), + }) client := github.NewClient(mockedClient) deps := BaseDeps{ @@ -2304,7 +2277,7 @@ func Test_ActionsRunTrigger_CancelWorkflowRun(t *testing.T) { }) t.Run("missing run_id for non-run_workflow methods", func(t *testing.T) { - mockedClient := mock.NewMockedHTTPClient() + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}) client := github.NewClient(mockedClient) deps := BaseDeps{ @@ -2351,15 +2324,12 @@ func Test_ActionsGetJobLogs_SingleJob(t *testing.T) { toolDef := ActionsGetJobLogs(translations.NullTranslationHelper) t.Run("successful single job logs with URL", func(t *testing.T) { - mockedClient := mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposActionsJobsLogsByOwnerByRepoByJobId, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Location", "https://github.com/logs/job/123") - w.WriteHeader(http.StatusFound) - }), - ), - ) + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposActionsJobsLogsByOwnerByRepoByJobID: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Location", "https://github.com/logs/job/123") + w.WriteHeader(http.StatusFound) + }), + }) client := github.NewClient(mockedClient) deps := BaseDeps{ @@ -2392,42 +2362,36 @@ func Test_ActionsGetJobLogs_FailedJobs(t *testing.T) { toolDef := ActionsGetJobLogs(translations.NullTranslationHelper) t.Run("successful failed jobs logs", func(t *testing.T) { - mockedClient := mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposActionsRunsJobsByOwnerByRepoByRunId, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - jobs := &github.Jobs{ - TotalCount: github.Ptr(3), - Jobs: []*github.WorkflowJob{ - { - ID: github.Ptr(int64(1)), - Name: github.Ptr("test-job-1"), - Conclusion: github.Ptr("success"), - }, - { - ID: github.Ptr(int64(2)), - Name: github.Ptr("test-job-2"), - Conclusion: github.Ptr("failure"), - }, - { - ID: github.Ptr(int64(3)), - Name: github.Ptr("test-job-3"), - Conclusion: github.Ptr("failure"), - }, + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposActionsRunsJobsByOwnerByRepoByRunID: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + jobs := &github.Jobs{ + TotalCount: github.Ptr(3), + Jobs: []*github.WorkflowJob{ + { + ID: github.Ptr(int64(1)), + Name: github.Ptr("test-job-1"), + Conclusion: github.Ptr("success"), }, - } - w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(jobs) - }), - ), - mock.WithRequestMatchHandler( - mock.GetReposActionsJobsLogsByOwnerByRepoByJobId, - http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Location", "https://github.com/logs/job/"+r.URL.Path[len(r.URL.Path)-1:]) - w.WriteHeader(http.StatusFound) - }), - ), - ) + { + ID: github.Ptr(int64(2)), + Name: github.Ptr("test-job-2"), + Conclusion: github.Ptr("failure"), + }, + { + ID: github.Ptr(int64(3)), + Name: github.Ptr("test-job-3"), + Conclusion: github.Ptr("failure"), + }, + }, + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(jobs) + }), + GetReposActionsJobsLogsByOwnerByRepoByJobID: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Location", "https://github.com/logs/job/"+r.URL.Path[len(r.URL.Path)-1:]) + w.WriteHeader(http.StatusFound) + }), + }) client := github.NewClient(mockedClient) deps := BaseDeps{ @@ -2457,30 +2421,27 @@ func Test_ActionsGetJobLogs_FailedJobs(t *testing.T) { }) t.Run("no failed jobs found", func(t *testing.T) { - mockedClient := mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposActionsRunsJobsByOwnerByRepoByRunId, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - jobs := &github.Jobs{ - TotalCount: github.Ptr(2), - Jobs: []*github.WorkflowJob{ - { - ID: github.Ptr(int64(1)), - Name: github.Ptr("test-job-1"), - Conclusion: github.Ptr("success"), - }, - { - ID: github.Ptr(int64(2)), - Name: github.Ptr("test-job-2"), - Conclusion: github.Ptr("success"), - }, + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposActionsRunsJobsByOwnerByRepoByRunID: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + jobs := &github.Jobs{ + TotalCount: github.Ptr(2), + Jobs: []*github.WorkflowJob{ + { + ID: github.Ptr(int64(1)), + Name: github.Ptr("test-job-1"), + Conclusion: github.Ptr("success"), }, - } - w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(jobs) - }), - ), - ) + { + ID: github.Ptr(int64(2)), + Name: github.Ptr("test-job-2"), + Conclusion: github.Ptr("success"), + }, + }, + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(jobs) + }), + }) client := github.NewClient(mockedClient) deps := BaseDeps{ diff --git a/pkg/github/code_scanning.go b/pkg/github/code_scanning.go index 5e25d0501..ccc00661a 100644 --- a/pkg/github/code_scanning.go +++ b/pkg/github/code_scanning.go @@ -8,6 +8,7 @@ import ( ghErrors "github.com/github/github-mcp-server/pkg/errors" "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" "github.com/google/go-github/v79/github" @@ -44,6 +45,7 @@ func GetCodeScanningAlert(t translations.TranslationHelperFunc) inventory.Server Required: []string{"owner", "repo", "alertNumber"}, }, }, + []scopes.Scope{scopes.SecurityEvents}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { @@ -135,6 +137,7 @@ func ListCodeScanningAlerts(t translations.TranslationHelperFunc) inventory.Serv Required: []string{"owner", "repo"}, }, }, + []scopes.Scope{scopes.SecurityEvents}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { diff --git a/pkg/github/context_tools.go b/pkg/github/context_tools.go index e0df82c88..29fa2925d 100644 --- a/pkg/github/context_tools.go +++ b/pkg/github/context_tools.go @@ -7,6 +7,7 @@ import ( ghErrors "github.com/github/github-mcp-server/pkg/errors" "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" "github.com/google/jsonschema-go/jsonschema" @@ -51,6 +52,7 @@ func GetMe(t translations.TranslationHelperFunc) inventory.ServerTool { // OpenAI strict mode requires the properties field to be present. InputSchema: json.RawMessage(`{"type":"object","properties":{}}`), }, + nil, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, _ map[string]any) (*mcp.CallToolResult, any, error) { client, err := deps.GetClient(ctx) if err != nil { @@ -129,6 +131,7 @@ func GetTeams(t translations.TranslationHelperFunc) inventory.ServerTool { }, }, }, + []scopes.Scope{scopes.ReadOrg}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { user, err := OptionalParam[string](args, "user") if err != nil { @@ -231,6 +234,7 @@ func GetTeamMembers(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"org", "team_slug"}, }, }, + []scopes.Scope{scopes.ReadOrg}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { org, err := RequiredParam[string](args, "org") if err != nil { diff --git a/pkg/github/dependabot.go b/pkg/github/dependabot.go index db6352dab..b6b2eeaba 100644 --- a/pkg/github/dependabot.go +++ b/pkg/github/dependabot.go @@ -9,6 +9,7 @@ import ( ghErrors "github.com/github/github-mcp-server/pkg/errors" "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" "github.com/google/go-github/v79/github" @@ -45,6 +46,7 @@ func GetDependabotAlert(t translations.TranslationHelperFunc) inventory.ServerTo Required: []string{"owner", "repo", "alertNumber"}, }, }, + []scopes.Scope{scopes.SecurityEvents}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { @@ -128,6 +130,7 @@ func ListDependabotAlerts(t translations.TranslationHelperFunc) inventory.Server Required: []string{"owner", "repo"}, }, }, + []scopes.Scope{scopes.SecurityEvents}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { diff --git a/pkg/github/dependencies.go b/pkg/github/dependencies.go index d23e993c3..b41bf0b87 100644 --- a/pkg/github/dependencies.go +++ b/pkg/github/dependencies.go @@ -7,6 +7,7 @@ import ( "github.com/github/github-mcp-server/pkg/inventory" "github.com/github/github-mcp-server/pkg/lockdown" "github.com/github/github-mcp-server/pkg/raw" + "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" gogithub "github.com/google/go-github/v79/github" "github.com/modelcontextprotocol/go-sdk/mcp" @@ -148,11 +149,23 @@ func (d BaseDeps) GetContentWindowSize() int { return d.ContentWindowSize } // // The handler function receives deps extracted from context via MustDepsFromContext. // Ensure ContextWithDeps is called to inject deps before any tool handlers are invoked. -func NewTool[In, Out any](toolset inventory.ToolsetMetadata, tool mcp.Tool, handler func(ctx context.Context, deps ToolDependencies, req *mcp.CallToolRequest, args In) (*mcp.CallToolResult, Out, error)) inventory.ServerTool { - return inventory.NewServerToolWithContextHandler(tool, toolset, func(ctx context.Context, req *mcp.CallToolRequest, args In) (*mcp.CallToolResult, Out, error) { +// +// requiredScopes specifies the minimum OAuth scopes needed for this tool. +// AcceptedScopes are automatically derived using the scope hierarchy (e.g., if +// public_repo is required, repo is also accepted since repo grants public_repo). +func NewTool[In, Out any]( + toolset inventory.ToolsetMetadata, + tool mcp.Tool, + requiredScopes []scopes.Scope, + handler func(ctx context.Context, deps ToolDependencies, req *mcp.CallToolRequest, args In) (*mcp.CallToolResult, Out, error), +) inventory.ServerTool { + st := inventory.NewServerToolWithContextHandler(tool, toolset, func(ctx context.Context, req *mcp.CallToolRequest, args In) (*mcp.CallToolResult, Out, error) { deps := MustDepsFromContext(ctx) return handler(ctx, deps, req, args) }) + st.RequiredScopes = scopes.ToStringSlice(requiredScopes...) + st.AcceptedScopes = scopes.ExpandScopes(requiredScopes...) + return st } // NewToolFromHandler creates a ServerTool that retrieves ToolDependencies from context at call time. @@ -160,9 +173,20 @@ func NewTool[In, Out any](toolset inventory.ToolsetMetadata, tool mcp.Tool, hand // // The handler function receives deps extracted from context via MustDepsFromContext. // Ensure ContextWithDeps is called to inject deps before any tool handlers are invoked. -func NewToolFromHandler(toolset inventory.ToolsetMetadata, tool mcp.Tool, handler func(ctx context.Context, deps ToolDependencies, req *mcp.CallToolRequest) (*mcp.CallToolResult, error)) inventory.ServerTool { - return inventory.NewServerToolWithRawContextHandler(tool, toolset, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { +// +// requiredScopes specifies the minimum OAuth scopes needed for this tool. +// AcceptedScopes are automatically derived using the scope hierarchy. +func NewToolFromHandler( + toolset inventory.ToolsetMetadata, + tool mcp.Tool, + requiredScopes []scopes.Scope, + handler func(ctx context.Context, deps ToolDependencies, req *mcp.CallToolRequest) (*mcp.CallToolResult, error), +) inventory.ServerTool { + st := inventory.NewServerToolWithRawContextHandler(tool, toolset, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { deps := MustDepsFromContext(ctx) return handler(ctx, deps, req) }) + st.RequiredScopes = scopes.ToStringSlice(requiredScopes...) + st.AcceptedScopes = scopes.ExpandScopes(requiredScopes...) + return st } diff --git a/pkg/github/deprecated_tool_aliases.go b/pkg/github/deprecated_tool_aliases.go index 63394770e..4415731fb 100644 --- a/pkg/github/deprecated_tool_aliases.go +++ b/pkg/github/deprecated_tool_aliases.go @@ -28,4 +28,15 @@ var DeprecatedToolAliases = map[string]string{ "rerun_failed_jobs": "actions_run_trigger", "cancel_workflow_run": "actions_run_trigger", "delete_workflow_run_logs": "actions_run_trigger", + + // Projects tools consolidated + "list_projects": "projects_list", + "list_project_fields": "projects_list", + "list_project_items": "projects_list", + "get_project": "projects_get", + "get_project_field": "projects_get", + "get_project_item": "projects_get", + "add_project_item": "projects_write", + "update_project_item": "projects_write", + "delete_project_item": "projects_write", } diff --git a/pkg/github/discussions.go b/pkg/github/discussions.go index c891ba294..c03670818 100644 --- a/pkg/github/discussions.go +++ b/pkg/github/discussions.go @@ -6,6 +6,7 @@ import ( "fmt" "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" "github.com/go-viper/mapstructure/v2" @@ -161,6 +162,7 @@ func ListDiscussions(t translations.TranslationHelperFunc) inventory.ServerTool Required: []string{"owner"}, }), }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { @@ -303,6 +305,7 @@ func GetDiscussion(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"owner", "repo", "discussionNumber"}, }, }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { // Decode params var params struct { @@ -406,6 +409,7 @@ func GetDiscussionComments(t translations.TranslationHelperFunc) inventory.Serve Required: []string{"owner", "repo", "discussionNumber"}, }), }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { // Decode params var params struct { @@ -528,6 +532,7 @@ func ListDiscussionCategories(t translations.TranslationHelperFunc) inventory.Se Required: []string{"owner"}, }, }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { diff --git a/pkg/github/gists.go b/pkg/github/gists.go index 4d741b88d..0f43ebdf9 100644 --- a/pkg/github/gists.go +++ b/pkg/github/gists.go @@ -9,6 +9,7 @@ import ( ghErrors "github.com/github/github-mcp-server/pkg/errors" "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" "github.com/google/go-github/v79/github" @@ -41,6 +42,7 @@ func ListGists(t translations.TranslationHelperFunc) inventory.ServerTool { }, }), }, + nil, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { username, err := OptionalParam[string](args, "username") if err != nil { @@ -124,6 +126,7 @@ func GetGist(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"gist_id"}, }, }, + nil, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { gistID, err := RequiredParam[string](args, "gist_id") if err != nil { @@ -194,6 +197,7 @@ func CreateGist(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"filename", "content"}, }, }, + []scopes.Scope{scopes.Gist}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { description, err := OptionalParam[string](args, "description") if err != nil { @@ -295,6 +299,7 @@ func UpdateGist(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"gist_id", "filename", "content"}, }, }, + []scopes.Scope{scopes.Gist}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { gistID, err := RequiredParam[string](args, "gist_id") if err != nil { diff --git a/pkg/github/git.go b/pkg/github/git.go index 7b93c3675..ec7159b9b 100644 --- a/pkg/github/git.go +++ b/pkg/github/git.go @@ -8,6 +8,7 @@ import ( ghErrors "github.com/github/github-mcp-server/pkg/errors" "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" "github.com/google/go-github/v79/github" @@ -76,6 +77,7 @@ func GetRepositoryTree(t translations.TranslationHelperFunc) inventory.ServerToo Required: []string{"owner", "repo"}, }, }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { diff --git a/pkg/github/helper_test.go b/pkg/github/helper_test.go index 56a236660..0bb73008e 100644 --- a/pkg/github/helper_test.go +++ b/pkg/github/helper_test.go @@ -11,7 +11,7 @@ import ( "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" + testifymock "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" ) @@ -41,9 +41,9 @@ const ( // Git endpoints GetReposGitTreesByOwnerByRepoByTree = "GET /repos/{owner}/{repo}/git/trees/{tree}" - GetReposGitRefByOwnerByRepoByRef = "GET /repos/{owner}/{repo}/git/ref/{ref}" + GetReposGitRefByOwnerByRepoByRef = "GET /repos/{owner}/{repo}/git/ref/{ref:.*}" PostReposGitRefsByOwnerByRepo = "POST /repos/{owner}/{repo}/git/refs" - PatchReposGitRefsByOwnerByRepoByRef = "PATCH /repos/{owner}/{repo}/git/refs/{ref}" + PatchReposGitRefsByOwnerByRepoByRef = "PATCH /repos/{owner}/{repo}/git/refs/{ref:.*}" GetReposGitCommitsByOwnerByRepoByCommitSHA = "GET /repos/{owner}/{repo}/git/commits/{commit_sha}" PostReposGitCommitsByOwnerByRepo = "POST /repos/{owner}/{repo}/git/commits" GetReposGitTagsByOwnerByRepoByTagSHA = "GET /repos/{owner}/{repo}/git/tags/{tag_sha}" @@ -59,7 +59,7 @@ const ( PatchReposIssuesByOwnerByRepoByIssueNumber = "PATCH /repos/{owner}/{repo}/issues/{issue_number}" GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber = "GET /repos/{owner}/{repo}/issues/{issue_number}/sub_issues" PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber = "POST /repos/{owner}/{repo}/issues/{issue_number}/sub_issues" - DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber = "DELETE /repos/{owner}/{repo}/issues/{issue_number}/sub_issues" + DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber = "DELETE /repos/{owner}/{repo}/issues/{issue_number}/sub_issue" PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber = "PATCH /repos/{owner}/{repo}/issues/{issue_number}/sub_issues/priority" // Pull request endpoints @@ -118,6 +118,7 @@ const ( GetReposActionsWorkflowsByOwnerByRepoByWorkflowID = "GET /repos/{owner}/{repo}/actions/workflows/{workflow_id}" PostReposActionsWorkflowsDispatchesByOwnerByRepoByWorkflowID = "POST /repos/{owner}/{repo}/actions/workflows/{workflow_id}/dispatches" GetReposActionsWorkflowsRunsByOwnerByRepoByWorkflowID = "GET /repos/{owner}/{repo}/actions/workflows/{workflow_id}/runs" + GetReposActionsRunsByOwnerByRepo = "GET /repos/{owner}/{repo}/actions/runs" GetReposActionsRunsByOwnerByRepoByRunID = "GET /repos/{owner}/{repo}/actions/runs/{run_id}" GetReposActionsRunsLogsByOwnerByRepoByRunID = "GET /repos/{owner}/{repo}/actions/runs/{run_id}/logs" GetReposActionsRunsJobsByOwnerByRepoByRunID = "GET /repos/{owner}/{repo}/actions/runs/{run_id}/jobs" @@ -132,8 +133,8 @@ const ( // Search endpoints GetSearchCode = "GET /search/code" GetSearchIssues = "GET /search/issues" - GetSearchRepositories = "GET /search/repositories" GetSearchUsers = "GET /search/users" + GetSearchRepositories = "GET /search/repositories" // Raw content endpoints (used for GitHub raw content API, not standard API) // These are used with the raw content client that interacts with raw.githubusercontent.com @@ -141,6 +142,31 @@ const ( GetRawReposContentsByOwnerByRepoByBranchByPath = "GET /{owner}/{repo}/refs/heads/{branch}/{path:.*}" GetRawReposContentsByOwnerByRepoByTagByPath = "GET /{owner}/{repo}/refs/tags/{tag}/{path:.*}" GetRawReposContentsByOwnerByRepoBySHAByPath = "GET /{owner}/{repo}/{sha}/{path:.*}" + + // Projects (ProjectsV2) endpoints + // Organization-scoped + GetOrgsProjectsV2 = "GET /orgs/{org}/projectsV2" + GetOrgsProjectsV2ByProject = "GET /orgs/{org}/projectsV2/{project}" + GetOrgsProjectsV2FieldsByProject = "GET /orgs/{org}/projectsV2/{project}/fields" + GetOrgsProjectsV2FieldsByProjectByFieldID = "GET /orgs/{org}/projectsV2/{project}/fields/{field_id}" + GetOrgsProjectsV2ItemsByProject = "GET /orgs/{org}/projectsV2/{project}/items" + GetOrgsProjectsV2ItemsByProjectByItemID = "GET /orgs/{org}/projectsV2/{project}/items/{item_id}" + PostOrgsProjectsV2ItemsByProject = "POST /orgs/{org}/projectsV2/{project}/items" + PatchOrgsProjectsV2ItemsByProjectByItemID = "PATCH /orgs/{org}/projectsV2/{project}/items/{item_id}" + DeleteOrgsProjectsV2ItemsByProjectByItemID = "DELETE /orgs/{org}/projectsV2/{project}/items/{item_id}" + // User-scoped + GetUsersProjectsV2ByUsername = "GET /users/{username}/projectsV2" + GetUsersProjectsV2ByUsernameByProject = "GET /users/{username}/projectsV2/{project}" + GetUsersProjectsV2FieldsByUsernameByProject = "GET /users/{username}/projectsV2/{project}/fields" + GetUsersProjectsV2FieldsByUsernameByProjectByFieldID = "GET /users/{username}/projectsV2/{project}/fields/{field_id}" + GetUsersProjectsV2ItemsByUsernameByProject = "GET /users/{username}/projectsV2/{project}/items" + GetUsersProjectsV2ItemsByUsernameByProjectByItemID = "GET /users/{username}/projectsV2/{project}/items/{item_id}" + PostUsersProjectsV2ItemsByUsernameByProject = "POST /users/{username}/projectsV2/{project}/items" + PatchUsersProjectsV2ItemsByUsernameByProjectByItemID = "PATCH /users/{username}/projectsV2/{project}/items/{item_id}" + DeleteUsersProjectsV2ItemsByUsernameByProjectByItemID = "DELETE /users/{username}/projectsV2/{project}/items/{item_id}" + + // Organization issue types endpoints + GetOrgsIssueTypesByOrg = "GET /orgs/{org}/issue-types" ) type expectations struct { @@ -408,7 +434,7 @@ func getResourceResult(t *testing.T, result *mcp.CallToolResult) *mcp.ResourceCo // MockRoundTripper is a mock HTTP transport using testify/mock type MockRoundTripper struct { - mock.Mock + testifymock.Mock handlers map[string]http.HandlerFunc } @@ -564,6 +590,64 @@ func MockHTTPClientWithHandlers(handlers map[string]http.HandlerFunc) *http.Clie return &http.Client{Transport: transport} } +// Compatibility helpers to replace github.com/migueleliasweb/go-github-mock in tests +type EndpointPattern string + +type MockBackendOption func(map[string]http.HandlerFunc) + +func parseEndpointPattern(p EndpointPattern) (string, string) { + parts := strings.SplitN(string(p), " ", 2) + if len(parts) != 2 { + return http.MethodGet, string(p) + } + return parts[0], parts[1] +} + +func WithRequestMatch(pattern EndpointPattern, response any) MockBackendOption { + return func(handlers map[string]http.HandlerFunc) { + method, path := parseEndpointPattern(pattern) + handlers[method+" "+path] = func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + switch v := response.(type) { + case string: + _, _ = w.Write([]byte(v)) + case []byte: + _, _ = w.Write(v) + default: + data, err := json.Marshal(v) + if err == nil { + _, _ = w.Write(data) + } + } + } + } +} + +func WithRequestMatchHandler(pattern EndpointPattern, handler http.HandlerFunc) MockBackendOption { + return func(handlers map[string]http.HandlerFunc) { + method, path := parseEndpointPattern(pattern) + handlers[method+" "+path] = handler + } +} + +func NewMockedHTTPClient(options ...MockBackendOption) *http.Client { + handlers := map[string]http.HandlerFunc{} + for _, opt := range options { + if opt != nil { + opt(handlers) + } + } + return MockHTTPClientWithHandlers(handlers) +} + +func MustMarshal(v any) []byte { + data, err := json.Marshal(v) + if err != nil { + panic(err) + } + return data +} + type multiHandlerTransport struct { handlers map[string]http.HandlerFunc } diff --git a/pkg/github/issues.go b/pkg/github/issues.go index f06dc2d9d..63174c9e9 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -14,6 +14,7 @@ import ( "github.com/github/github-mcp-server/pkg/lockdown" "github.com/github/github-mcp-server/pkg/octicons" "github.com/github/github-mcp-server/pkg/sanitize" + "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" "github.com/go-viper/mapstructure/v2" @@ -274,6 +275,7 @@ Options are: }, InputSchema: schema, }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { method, err := RequiredParam[string](args, "method") if err != nil { @@ -565,6 +567,7 @@ func ListIssueTypes(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"owner"}, }, }, + []scopes.Scope{scopes.ReadOrg}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { @@ -632,6 +635,7 @@ func AddIssueComment(t translations.TranslationHelperFunc) inventory.ServerTool Required: []string{"owner", "repo", "issue_number", "body"}, }, }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { @@ -736,6 +740,7 @@ Options are: Required: []string{"method", "owner", "repo", "issue_number", "sub_issue_id"}, }, }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { method, err := RequiredParam[string](args, "method") if err != nil { @@ -963,6 +968,7 @@ func SearchIssues(t translations.TranslationHelperFunc) inventory.ServerTool { }, InputSchema: schema, }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { result, err := searchHandler(ctx, deps.GetClient, args, "issue", "failed to search issues") return result, nil, err @@ -1052,6 +1058,7 @@ Options are: Required: []string{"method", "owner", "repo"}, }, }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { method, err := RequiredParam[string](args, "method") if err != nil { @@ -1175,7 +1182,11 @@ func CreateIssue(ctx context.Context, client *github.Client, owner string, repo issue, resp, err := client.Issues.Create(ctx, owner, repo, issueRequest) if err != nil { - return utils.NewToolResultErrorFromErr("failed to create issue", err), nil + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to create issue", + resp, + err, + ), nil } defer func() { _ = resp.Body.Close() }() @@ -1381,6 +1392,7 @@ func ListIssues(t translations.TranslationHelperFunc) inventory.ServerTool { }, InputSchema: schema, }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { @@ -1522,7 +1534,11 @@ func ListIssues(t translations.TranslationHelperFunc) inventory.ServerTool { issueQuery := getIssueQueryType(hasLabels, hasSince) if err := client.Query(ctx, issueQuery, vars); err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + return ghErrors.NewGitHubGraphQLErrorResponse( + ctx, + "failed to list issues", + err, + ), nil, nil } // Extract and convert all issue nodes using the common interface @@ -1626,19 +1642,25 @@ func AssignCopilotToIssue(t translations.TranslationHelperFunc) inventory.Server Type: "string", Description: "Repository name", }, - "issueNumber": { + "issue_number": { Type: "number", Description: "Issue number", }, + "base_ref": { + Type: "string", + Description: "Git reference (e.g., branch) that the agent will start its work from. If not specified, defaults to the repository's default branch", + }, }, - Required: []string{"owner", "repo", "issueNumber"}, + Required: []string{"owner", "repo", "issue_number"}, }, }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { var params struct { - Owner string - Repo string - IssueNumber int32 + Owner string `mapstructure:"owner"` + Repo string `mapstructure:"repo"` + IssueNumber int32 `mapstructure:"issue_number"` + BaseRef string `mapstructure:"base_ref"` } if err := mapstructure.Decode(args, ¶ms); err != nil { return utils.NewToolResultError(err.Error()), nil, nil @@ -1683,7 +1705,7 @@ func AssignCopilotToIssue(t translations.TranslationHelperFunc) inventory.Server var query suggestedActorsQuery err := client.Query(ctx, &query, variables) if err != nil { - return nil, nil, err + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to get suggested actors", err), nil, nil } // Iterate all the returned nodes looking for the copilot bot, which is supposed to have the @@ -1707,10 +1729,10 @@ func AssignCopilotToIssue(t translations.TranslationHelperFunc) inventory.Server return utils.NewToolResultError("copilot isn't available as an assignee for this issue. Please inform the user to visit https://docs.github.com/en/copilot/using-github-copilot/using-copilot-coding-agent-to-work-on-tasks/about-assigning-tasks-to-copilot for more information."), nil, nil } - // Next let's get the GQL Node ID and current assignees for this issue because the only way to - // assign copilot is to use replaceActorsForAssignable which requires the full list. + // Next, get the issue ID and repository ID var getIssueQuery struct { Repository struct { + ID githubv4.ID Issue struct { ID githubv4.ID Assignees struct { @@ -1729,33 +1751,57 @@ func AssignCopilotToIssue(t translations.TranslationHelperFunc) inventory.Server } if err := client.Query(ctx, &getIssueQuery, variables); err != nil { - return utils.NewToolResultError(fmt.Sprintf("failed to get issue ID: %v", err)), nil, nil - } - - // Finally, do the assignment. Just for reference, assigning copilot to an issue that it is already - // assigned to seems to have no impact (which is a good thing). - var assignCopilotMutation struct { - ReplaceActorsForAssignable struct { - Typename string `graphql:"__typename"` // Not required but we need a selector or GQL errors - } `graphql:"replaceActorsForAssignable(input: $input)"` + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to get issue ID", err), nil, nil } + // Build the assignee IDs list including copilot actorIDs := make([]githubv4.ID, len(getIssueQuery.Repository.Issue.Assignees.Nodes)+1) for i, node := range getIssueQuery.Repository.Issue.Assignees.Nodes { actorIDs[i] = node.ID } actorIDs[len(getIssueQuery.Repository.Issue.Assignees.Nodes)] = copilotAssignee.ID + // Prepare agent assignment input + emptyString := githubv4.String("") + agentAssignment := &AgentAssignmentInput{ + CustomAgent: &emptyString, + CustomInstructions: &emptyString, + TargetRepositoryID: getIssueQuery.Repository.ID, + } + + // Add base ref if provided + if params.BaseRef != "" { + baseRef := githubv4.String(params.BaseRef) + agentAssignment.BaseRef = &baseRef + } + + // Execute the updateIssue mutation with the GraphQL-Features header + // This header is required for the agent assignment API which is not GA yet + var updateIssueMutation struct { + UpdateIssue struct { + Issue struct { + ID githubv4.ID + Number githubv4.Int + URL githubv4.String + } + } `graphql:"updateIssue(input: $input)"` + } + + // Add the GraphQL-Features header for the agent assignment API + // The header will be read by the HTTP transport if it's configured to do so + ctxWithFeatures := withGraphQLFeatures(ctx, "issues_copilot_assignment_api_support") + if err := client.Mutate( - ctx, - &assignCopilotMutation, - ReplaceActorsForAssignableInput{ - AssignableID: getIssueQuery.Repository.Issue.ID, - ActorIDs: actorIDs, + ctxWithFeatures, + &updateIssueMutation, + UpdateIssueInput{ + ID: getIssueQuery.Repository.Issue.ID, + AssigneeIDs: actorIDs, + AgentAssignment: agentAssignment, }, nil, ); err != nil { - return nil, nil, fmt.Errorf("failed to replace actors for assignable: %w", err) + return nil, nil, fmt.Errorf("failed to update issue with agent assignment: %w", err) } return utils.NewToolResultText("successfully assigned copilot to issue"), nil, nil @@ -1767,6 +1813,21 @@ type ReplaceActorsForAssignableInput struct { ActorIDs []githubv4.ID `json:"actorIds"` } +// AgentAssignmentInput represents the input for assigning an agent to an issue. +type AgentAssignmentInput struct { + BaseRef *githubv4.String `json:"baseRef,omitempty"` + CustomAgent *githubv4.String `json:"customAgent,omitempty"` + CustomInstructions *githubv4.String `json:"customInstructions,omitempty"` + TargetRepositoryID githubv4.ID `json:"targetRepositoryId"` +} + +// UpdateIssueInput represents the input for updating an issue with agent assignment. +type UpdateIssueInput struct { + ID githubv4.ID `json:"id"` + AssigneeIDs []githubv4.ID `json:"assigneeIds"` + AgentAssignment *AgentAssignmentInput `json:"agentAssignment,omitempty"` +} + // parseISOTimestamp parses an ISO 8601 timestamp string into a time.Time object. // Returns the parsed time or an error if parsing fails. // Example formats supported: "2023-01-15T14:30:00Z", "2023-01-15" @@ -1852,3 +1913,19 @@ func AssignCodingAgentPrompt(t translations.TranslationHelperFunc) inventory.Ser }, ) } + +// graphQLFeaturesKey is a context key for GraphQL feature flags +type graphQLFeaturesKey struct{} + +// withGraphQLFeatures adds GraphQL feature flags to the context +func withGraphQLFeatures(ctx context.Context, features ...string) context.Context { + return context.WithValue(ctx, graphQLFeaturesKey{}, features) +} + +// GetGraphQLFeatures retrieves GraphQL feature flags from the context +func GetGraphQLFeatures(ctx context.Context) []string { + if features, ok := ctx.Value(graphQLFeaturesKey{}).([]string); ok { + return features + } + return nil +} diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go index b810cede3..21e78874a 100644 --- a/pkg/github/issues_test.go +++ b/pkg/github/issues_test.go @@ -17,7 +17,6 @@ import ( "github.com/github/github-mcp-server/pkg/translations" "github.com/google/go-github/v79/github" "github.com/google/jsonschema-go/jsonschema" - "github.com/migueleliasweb/go-github-mock/src/mock" "github.com/shurcooL/githubv4" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -181,12 +180,9 @@ func Test_GetIssue(t *testing.T) { }{ { name: "successful issue retrieval", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetReposIssuesByOwnerByRepoByIssueNumber, - mockIssue, - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockIssue), + }), requestArgs: map[string]interface{}{ "method": "get", "owner": "owner2", @@ -197,12 +193,9 @@ func Test_GetIssue(t *testing.T) { }, { name: "issue not found", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposIssuesByOwnerByRepoByIssueNumber, - mockResponse(t, http.StatusNotFound, `{"message": "Issue not found"}`), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusNotFound, `{"message": "Issue not found"}`), + }), requestArgs: map[string]interface{}{ "method": "get", "owner": "owner", @@ -214,12 +207,9 @@ func Test_GetIssue(t *testing.T) { }, { name: "lockdown enabled - private repository", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetReposIssuesByOwnerByRepoByIssueNumber, - mockIssue2, - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockIssue2), + }), gqlHTTPClient: githubv4mock.NewMockedHTTPClient( githubv4mock.NewQueryMatcher( struct { @@ -261,12 +251,9 @@ func Test_GetIssue(t *testing.T) { }, { name: "lockdown enabled - user lacks push access", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetReposIssuesByOwnerByRepoByIssueNumber, - mockIssue, - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockIssue), + }), gqlHTTPClient: githubv4mock.NewMockedHTTPClient( githubv4mock.NewQueryMatcher( struct { @@ -406,12 +393,9 @@ func Test_AddIssueComment(t *testing.T) { }{ { name: "successful comment creation", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PostReposIssuesCommentsByOwnerByRepoByIssueNumber, - mockResponse(t, http.StatusCreated, mockComment), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposIssuesCommentsByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusCreated, mockComment), + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -423,15 +407,12 @@ func Test_AddIssueComment(t *testing.T) { }, { name: "comment creation fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PostReposIssuesCommentsByOwnerByRepoByIssueNumber, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusUnprocessableEntity) - _, _ = w.Write([]byte(`{"message": "Invalid request"}`)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposIssuesCommentsByOwnerByRepoByIssueNumber: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnprocessableEntity) + _, _ = w.Write([]byte(`{"message": "Invalid request"}`)) + }), + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -546,23 +527,20 @@ func Test_SearchIssues(t *testing.T) { }{ { name: "successful issues search with all parameters", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchIssues, - expectQueryParams( - t, - map[string]string{ - "q": "is:issue repo:owner/repo is:open", - "sort": "created", - "order": "desc", - "page": "1", - "per_page": "30", - }, - ).andThen( - mockResponse(t, http.StatusOK, mockSearchResult), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchIssues: expectQueryParams( + t, + map[string]string{ + "q": "is:issue repo:owner/repo is:open", + "sort": "created", + "order": "desc", + "page": "1", + "per_page": "30", + }, + ).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), ), - ), + }), requestArgs: map[string]interface{}{ "query": "repo:owner/repo is:open", "sort": "created", @@ -575,23 +553,20 @@ func Test_SearchIssues(t *testing.T) { }, { name: "issues search with owner and repo parameters", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchIssues, - expectQueryParams( - t, - map[string]string{ - "q": "repo:test-owner/test-repo is:issue is:open", - "sort": "created", - "order": "asc", - "page": "1", - "per_page": "30", - }, - ).andThen( - mockResponse(t, http.StatusOK, mockSearchResult), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchIssues: expectQueryParams( + t, + map[string]string{ + "q": "repo:test-owner/test-repo is:issue is:open", + "sort": "created", + "order": "asc", + "page": "1", + "per_page": "30", + }, + ).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), ), - ), + }), requestArgs: map[string]interface{}{ "query": "is:open", "owner": "test-owner", @@ -604,21 +579,18 @@ func Test_SearchIssues(t *testing.T) { }, { name: "issues search with only owner parameter (should ignore it)", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchIssues, - expectQueryParams( - t, - map[string]string{ - "q": "is:issue bug", - "page": "1", - "per_page": "30", - }, - ).andThen( - mockResponse(t, http.StatusOK, mockSearchResult), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchIssues: expectQueryParams( + t, + map[string]string{ + "q": "is:issue bug", + "page": "1", + "per_page": "30", + }, + ).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), ), - ), + }), requestArgs: map[string]interface{}{ "query": "bug", "owner": "test-owner", @@ -628,21 +600,18 @@ func Test_SearchIssues(t *testing.T) { }, { name: "issues search with only repo parameter (should ignore it)", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchIssues, - expectQueryParams( - t, - map[string]string{ - "q": "is:issue feature", - "page": "1", - "per_page": "30", - }, - ).andThen( - mockResponse(t, http.StatusOK, mockSearchResult), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchIssues: expectQueryParams( + t, + map[string]string{ + "q": "is:issue feature", + "page": "1", + "per_page": "30", + }, + ).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), ), - ), + }), requestArgs: map[string]interface{}{ "query": "feature", "repo": "test-repo", @@ -652,12 +621,9 @@ func Test_SearchIssues(t *testing.T) { }, { name: "issues search with minimal parameters", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetSearchIssues, - mockSearchResult, - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchIssues: mockResponse(t, http.StatusOK, mockSearchResult), + }), requestArgs: map[string]interface{}{ "query": "is:issue repo:owner/repo is:open", }, @@ -666,21 +632,18 @@ func Test_SearchIssues(t *testing.T) { }, { name: "query with existing is:issue filter - no duplication", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchIssues, - expectQueryParams( - t, - map[string]string{ - "q": "repo:github/github-mcp-server is:issue is:open (label:critical OR label:urgent)", - "page": "1", - "per_page": "30", - }, - ).andThen( - mockResponse(t, http.StatusOK, mockSearchResult), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchIssues: expectQueryParams( + t, + map[string]string{ + "q": "repo:github/github-mcp-server is:issue is:open (label:critical OR label:urgent)", + "page": "1", + "per_page": "30", + }, + ).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), ), - ), + }), requestArgs: map[string]interface{}{ "query": "repo:github/github-mcp-server is:issue is:open (label:critical OR label:urgent)", }, @@ -689,21 +652,18 @@ func Test_SearchIssues(t *testing.T) { }, { name: "query with existing repo: filter and conflicting owner/repo params - uses query filter", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchIssues, - expectQueryParams( - t, - map[string]string{ - "q": "is:issue repo:github/github-mcp-server critical", - "page": "1", - "per_page": "30", - }, - ).andThen( - mockResponse(t, http.StatusOK, mockSearchResult), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchIssues: expectQueryParams( + t, + map[string]string{ + "q": "is:issue repo:github/github-mcp-server critical", + "page": "1", + "per_page": "30", + }, + ).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), ), - ), + }), requestArgs: map[string]interface{}{ "query": "repo:github/github-mcp-server critical", "owner": "different-owner", @@ -714,21 +674,18 @@ func Test_SearchIssues(t *testing.T) { }, { name: "query with both is: and repo: filters already present", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchIssues, - expectQueryParams( - t, - map[string]string{ - "q": "is:issue repo:octocat/Hello-World bug", - "page": "1", - "per_page": "30", - }, - ).andThen( - mockResponse(t, http.StatusOK, mockSearchResult), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchIssues: expectQueryParams( + t, + map[string]string{ + "q": "is:issue repo:octocat/Hello-World bug", + "page": "1", + "per_page": "30", + }, + ).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), ), - ), + }), requestArgs: map[string]interface{}{ "query": "is:issue repo:octocat/Hello-World bug", }, @@ -737,21 +694,18 @@ func Test_SearchIssues(t *testing.T) { }, { name: "complex query with multiple OR operators and existing filters", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchIssues, - expectQueryParams( - t, - map[string]string{ - "q": "repo:github/github-mcp-server is:issue (label:critical OR label:urgent OR label:high-priority OR label:blocker)", - "page": "1", - "per_page": "30", - }, - ).andThen( - mockResponse(t, http.StatusOK, mockSearchResult), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchIssues: expectQueryParams( + t, + map[string]string{ + "q": "repo:github/github-mcp-server is:issue (label:critical OR label:urgent OR label:high-priority OR label:blocker)", + "page": "1", + "per_page": "30", + }, + ).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), ), - ), + }), requestArgs: map[string]interface{}{ "query": "repo:github/github-mcp-server is:issue (label:critical OR label:urgent OR label:high-priority OR label:blocker)", }, @@ -760,15 +714,12 @@ func Test_SearchIssues(t *testing.T) { }, { name: "search issues fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchIssues, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusBadRequest) - _, _ = w.Write([]byte(`{"message": "Validation Failed"}`)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchIssues: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"message": "Validation Failed"}`)) + }), + }), requestArgs: map[string]interface{}{ "query": "invalid:query", }, @@ -868,21 +819,18 @@ func Test_CreateIssue(t *testing.T) { }{ { name: "successful issue creation with all fields", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PostReposIssuesByOwnerByRepo, - expectRequestBody(t, map[string]any{ - "title": "Test Issue", - "body": "This is a test issue", - "labels": []any{"bug", "help wanted"}, - "assignees": []any{"user1", "user2"}, - "milestone": float64(5), - "type": "Bug", - }).andThen( - mockResponse(t, http.StatusCreated, mockIssue), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposIssuesByOwnerByRepo: expectRequestBody(t, map[string]any{ + "title": "Test Issue", + "body": "This is a test issue", + "labels": []any{"bug", "help wanted"}, + "assignees": []any{"user1", "user2"}, + "milestone": float64(5), + "type": "Bug", + }).andThen( + mockResponse(t, http.StatusCreated, mockIssue), ), - ), + }), requestArgs: map[string]interface{}{ "method": "create", "owner": "owner", @@ -899,17 +847,14 @@ func Test_CreateIssue(t *testing.T) { }, { name: "successful issue creation with minimal fields", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PostReposIssuesByOwnerByRepo, - mockResponse(t, http.StatusCreated, &github.Issue{ - Number: github.Ptr(124), - Title: github.Ptr("Minimal Issue"), - HTMLURL: github.Ptr("https://github.com/owner/repo/issues/124"), - State: github.Ptr("open"), - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposIssuesByOwnerByRepo: mockResponse(t, http.StatusCreated, &github.Issue{ + Number: github.Ptr(124), + Title: github.Ptr("Minimal Issue"), + HTMLURL: github.Ptr("https://github.com/owner/repo/issues/124"), + State: github.Ptr("open"), + }), + }), requestArgs: map[string]interface{}{ "method": "create", "owner": "owner", @@ -927,15 +872,12 @@ func Test_CreateIssue(t *testing.T) { }, { name: "issue creation fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PostReposIssuesByOwnerByRepo, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusUnprocessableEntity) - _, _ = w.Write([]byte(`{"message": "Validation failed"}`)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposIssuesByOwnerByRepo: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnprocessableEntity) + _, _ = w.Write([]byte(`{"message": "Validation failed"}`)) + }), + }), requestArgs: map[string]interface{}{ "method": "create", "owner": "owner", @@ -1427,17 +1369,14 @@ func Test_UpdateIssue(t *testing.T) { }{ { name: "partial update of non-state fields only", - mockedRESTClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PatchReposIssuesByOwnerByRepoByIssueNumber, - expectRequestBody(t, map[string]interface{}{ - "title": "Updated Title", - "body": "Updated Description", - }).andThen( - mockResponse(t, http.StatusOK, mockUpdatedIssue), - ), - ), - ), + mockedRESTClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposIssuesByOwnerByRepoByIssueNumber: expectRequestBody(t, map[string]interface{}{ + "title": "Updated Title", + "body": "Updated Description", + }).andThen( + mockResponse(t, http.StatusOK, mockUpdatedIssue), + ), + }), mockedGQLClient: githubv4mock.NewMockedHTTPClient(), requestArgs: map[string]interface{}{ "method": "update", @@ -1452,15 +1391,12 @@ func Test_UpdateIssue(t *testing.T) { }, { name: "issue not found when updating non-state fields only", - mockedRESTClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PatchReposIssuesByOwnerByRepoByIssueNumber, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"message": "Not Found"}`)) - }), - ), - ), + mockedRESTClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposIssuesByOwnerByRepoByIssueNumber: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + }), + }), mockedGQLClient: githubv4mock.NewMockedHTTPClient(), requestArgs: map[string]interface{}{ "method": "update", @@ -1474,12 +1410,9 @@ func Test_UpdateIssue(t *testing.T) { }, { name: "close issue as duplicate", - mockedRESTClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.PatchReposIssuesByOwnerByRepoByIssueNumber, - mockBaseIssue, - ), - ), + mockedRESTClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockBaseIssue), + }), mockedGQLClient: githubv4mock.NewMockedHTTPClient( githubv4mock.NewQueryMatcher( struct { @@ -1534,12 +1467,9 @@ func Test_UpdateIssue(t *testing.T) { }, { name: "reopen issue", - mockedRESTClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.PatchReposIssuesByOwnerByRepoByIssueNumber, - mockBaseIssue, - ), - ), + mockedRESTClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockBaseIssue), + }), mockedGQLClient: githubv4mock.NewMockedHTTPClient( githubv4mock.NewQueryMatcher( struct { @@ -1586,12 +1516,9 @@ func Test_UpdateIssue(t *testing.T) { }, { name: "main issue not found when trying to close it", - mockedRESTClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.PatchReposIssuesByOwnerByRepoByIssueNumber, - mockBaseIssue, - ), - ), + mockedRESTClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockBaseIssue), + }), mockedGQLClient: githubv4mock.NewMockedHTTPClient( githubv4mock.NewQueryMatcher( struct { @@ -1622,12 +1549,9 @@ func Test_UpdateIssue(t *testing.T) { }, { name: "duplicate issue not found when closing as duplicate", - mockedRESTClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.PatchReposIssuesByOwnerByRepoByIssueNumber, - mockBaseIssue, - ), - ), + mockedRESTClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockBaseIssue), + }), mockedGQLClient: githubv4mock.NewMockedHTTPClient( githubv4mock.NewQueryMatcher( struct { @@ -1663,31 +1587,28 @@ func Test_UpdateIssue(t *testing.T) { }, { name: "close as duplicate with combined non-state updates", - mockedRESTClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PatchReposIssuesByOwnerByRepoByIssueNumber, - expectRequestBody(t, map[string]interface{}{ - "title": "Updated Title", - "body": "Updated Description", - "labels": []any{"bug", "priority"}, - "assignees": []any{"assignee1", "assignee2"}, - "milestone": float64(5), - "type": "Bug", - }).andThen( - mockResponse(t, http.StatusOK, &github.Issue{ - Number: github.Ptr(123), - Title: github.Ptr("Updated Title"), - Body: github.Ptr("Updated Description"), - Labels: []*github.Label{{Name: github.Ptr("bug")}, {Name: github.Ptr("priority")}}, - Assignees: []*github.User{{Login: github.Ptr("assignee1")}, {Login: github.Ptr("assignee2")}}, - Milestone: &github.Milestone{Number: github.Ptr(5)}, - Type: &github.IssueType{Name: github.Ptr("Bug")}, - State: github.Ptr("open"), // Still open after REST update - HTMLURL: github.Ptr("https://github.com/owner/repo/issues/123"), - }), - ), + mockedRESTClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposIssuesByOwnerByRepoByIssueNumber: expectRequestBody(t, map[string]interface{}{ + "title": "Updated Title", + "body": "Updated Description", + "labels": []any{"bug", "priority"}, + "assignees": []any{"assignee1", "assignee2"}, + "milestone": float64(5), + "type": "Bug", + }).andThen( + mockResponse(t, http.StatusOK, &github.Issue{ + Number: github.Ptr(123), + Title: github.Ptr("Updated Title"), + Body: github.Ptr("Updated Description"), + Labels: []*github.Label{{Name: github.Ptr("bug")}, {Name: github.Ptr("priority")}}, + Assignees: []*github.User{{Login: github.Ptr("assignee1")}, {Login: github.Ptr("assignee2")}}, + Milestone: &github.Milestone{Number: github.Ptr(5)}, + Type: &github.IssueType{Name: github.Ptr("Bug")}, + State: github.Ptr("open"), // Still open after REST update + HTMLURL: github.Ptr("https://github.com/owner/repo/issues/123"), + }), ), - ), + }), mockedGQLClient: githubv4mock.NewMockedHTTPClient( githubv4mock.NewQueryMatcher( struct { @@ -1748,7 +1669,7 @@ func Test_UpdateIssue(t *testing.T) { }, { name: "duplicate_of without duplicate state_reason should fail", - mockedRESTClient: mock.NewMockedHTTPClient(), + mockedRESTClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), mockedGQLClient: githubv4mock.NewMockedHTTPClient(), requestArgs: map[string]interface{}{ "method": "update", @@ -1910,12 +1831,9 @@ func Test_GetIssueComments(t *testing.T) { }{ { name: "successful comments retrieval", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetReposIssuesCommentsByOwnerByRepoByIssueNumber, - mockComments, - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposIssuesCommentsByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockComments), + }), requestArgs: map[string]interface{}{ "method": "get_comments", "owner": "owner", @@ -1927,17 +1845,14 @@ func Test_GetIssueComments(t *testing.T) { }, { name: "successful comments retrieval with pagination", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposIssuesCommentsByOwnerByRepoByIssueNumber, - expectQueryParams(t, map[string]string{ - "page": "2", - "per_page": "10", - }).andThen( - mockResponse(t, http.StatusOK, mockComments), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposIssuesCommentsByOwnerByRepoByIssueNumber: expectQueryParams(t, map[string]string{ + "page": "2", + "per_page": "10", + }).andThen( + mockResponse(t, http.StatusOK, mockComments), ), - ), + }), requestArgs: map[string]interface{}{ "method": "get_comments", "owner": "owner", @@ -1951,12 +1866,9 @@ func Test_GetIssueComments(t *testing.T) { }, { name: "issue not found", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposIssuesCommentsByOwnerByRepoByIssueNumber, - mockResponse(t, http.StatusNotFound, `{"message": "Issue not found"}`), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposIssuesCommentsByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusNotFound, `{"message": "Issue not found"}`), + }), requestArgs: map[string]interface{}{ "method": "get_comments", "owner": "owner", @@ -1968,23 +1880,20 @@ func Test_GetIssueComments(t *testing.T) { }, { name: "lockdown enabled filters comments without push access", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetReposIssuesCommentsByOwnerByRepoByIssueNumber, - []*github.IssueComment{ - { - ID: github.Ptr(int64(789)), - Body: github.Ptr("Maintainer comment"), - User: &github.User{Login: github.Ptr("maintainer")}, - }, - { - ID: github.Ptr(int64(790)), - Body: github.Ptr("External user comment"), - User: &github.User{Login: github.Ptr("testuser")}, - }, + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposIssuesCommentsByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, []*github.IssueComment{ + { + ID: github.Ptr(int64(789)), + Body: github.Ptr("Maintainer comment"), + User: &github.User{Login: github.Ptr("maintainer")}, }, - ), - ), + { + ID: github.Ptr(int64(790)), + Body: github.Ptr("External user comment"), + User: &github.User{Login: github.Ptr("testuser")}, + }, + }), + }), gqlHTTPClient: newRepoAccessHTTPClient(), requestArgs: map[string]interface{}{ "method": "get_comments", @@ -2175,8 +2084,15 @@ func TestAssignCopilotToIssue(t *testing.T) { assert.NotEmpty(t, tool.Description) assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "owner") assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "repo") - assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "issueNumber") - assert.ElementsMatch(t, tool.InputSchema.(*jsonschema.Schema).Required, []string{"owner", "repo", "issueNumber"}) + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "issue_number") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "base_ref") + assert.ElementsMatch(t, tool.InputSchema.(*jsonschema.Schema).Required, []string{"owner", "repo", "issue_number"}) + + // Helper function to create pointer to githubv4.String + ptrGitHubv4String := func(s string) *githubv4.String { + v := githubv4.String(s) + return &v + } var pageOfFakeBots = func(n int) []struct{} { // We don't _really_ need real bots here, just objects that count as entries for the page @@ -2197,9 +2113,9 @@ func TestAssignCopilotToIssue(t *testing.T) { { name: "successful assignment when there are no existing assignees", requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "issueNumber": float64(123), + "owner": "owner", + "repo": "repo", + "issue_number": float64(123), }, mockedClient: githubv4mock.NewMockedHTTPClient( githubv4mock.NewQueryMatcher( @@ -2242,6 +2158,7 @@ func TestAssignCopilotToIssue(t *testing.T) { githubv4mock.NewQueryMatcher( struct { Repository struct { + ID githubv4.ID Issue struct { ID githubv4.ID Assignees struct { @@ -2259,6 +2176,7 @@ func TestAssignCopilotToIssue(t *testing.T) { }, githubv4mock.DataResponse(map[string]any{ "repository": map[string]any{ + "id": githubv4.ID("test-repo-id"), "issue": map[string]any{ "id": githubv4.ID("test-issue-id"), "assignees": map[string]any{ @@ -2270,25 +2188,43 @@ func TestAssignCopilotToIssue(t *testing.T) { ), githubv4mock.NewMutationMatcher( struct { - ReplaceActorsForAssignable struct { - Typename string `graphql:"__typename"` - } `graphql:"replaceActorsForAssignable(input: $input)"` + UpdateIssue struct { + Issue struct { + ID githubv4.ID + Number githubv4.Int + URL githubv4.String + } + } `graphql:"updateIssue(input: $input)"` }{}, - ReplaceActorsForAssignableInput{ - AssignableID: githubv4.ID("test-issue-id"), - ActorIDs: []githubv4.ID{githubv4.ID("copilot-swe-agent-id")}, + UpdateIssueInput{ + ID: githubv4.ID("test-issue-id"), + AssigneeIDs: []githubv4.ID{githubv4.ID("copilot-swe-agent-id")}, + AgentAssignment: &AgentAssignmentInput{ + BaseRef: nil, + CustomAgent: ptrGitHubv4String(""), + CustomInstructions: ptrGitHubv4String(""), + TargetRepositoryID: githubv4.ID("test-repo-id"), + }, }, nil, - githubv4mock.DataResponse(map[string]any{}), + githubv4mock.DataResponse(map[string]any{ + "updateIssue": map[string]any{ + "issue": map[string]any{ + "id": githubv4.ID("test-issue-id"), + "number": githubv4.Int(123), + "url": githubv4.String("https://github.com/owner/repo/issues/123"), + }, + }, + }), ), ), }, { name: "successful assignment when there are existing assignees", requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "issueNumber": float64(123), + "owner": "owner", + "repo": "repo", + "issue_number": float64(123), }, mockedClient: githubv4mock.NewMockedHTTPClient( githubv4mock.NewQueryMatcher( @@ -2331,6 +2267,7 @@ func TestAssignCopilotToIssue(t *testing.T) { githubv4mock.NewQueryMatcher( struct { Repository struct { + ID githubv4.ID Issue struct { ID githubv4.ID Assignees struct { @@ -2348,6 +2285,7 @@ func TestAssignCopilotToIssue(t *testing.T) { }, githubv4mock.DataResponse(map[string]any{ "repository": map[string]any{ + "id": githubv4.ID("test-repo-id"), "issue": map[string]any{ "id": githubv4.ID("test-issue-id"), "assignees": map[string]any{ @@ -2366,29 +2304,47 @@ func TestAssignCopilotToIssue(t *testing.T) { ), githubv4mock.NewMutationMatcher( struct { - ReplaceActorsForAssignable struct { - Typename string `graphql:"__typename"` - } `graphql:"replaceActorsForAssignable(input: $input)"` + UpdateIssue struct { + Issue struct { + ID githubv4.ID + Number githubv4.Int + URL githubv4.String + } + } `graphql:"updateIssue(input: $input)"` }{}, - ReplaceActorsForAssignableInput{ - AssignableID: githubv4.ID("test-issue-id"), - ActorIDs: []githubv4.ID{ + UpdateIssueInput{ + ID: githubv4.ID("test-issue-id"), + AssigneeIDs: []githubv4.ID{ githubv4.ID("existing-assignee-id"), githubv4.ID("existing-assignee-id-2"), githubv4.ID("copilot-swe-agent-id"), }, + AgentAssignment: &AgentAssignmentInput{ + BaseRef: nil, + CustomAgent: ptrGitHubv4String(""), + CustomInstructions: ptrGitHubv4String(""), + TargetRepositoryID: githubv4.ID("test-repo-id"), + }, }, nil, - githubv4mock.DataResponse(map[string]any{}), + githubv4mock.DataResponse(map[string]any{ + "updateIssue": map[string]any{ + "issue": map[string]any{ + "id": githubv4.ID("test-issue-id"), + "number": githubv4.Int(123), + "url": githubv4.String("https://github.com/owner/repo/issues/123"), + }, + }, + }), ), ), }, { name: "copilot bot not on first page of suggested actors", requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "issueNumber": float64(123), + "owner": "owner", + "repo": "repo", + "issue_number": float64(123), }, mockedClient: githubv4mock.NewMockedHTTPClient( // First page of suggested actors @@ -2468,6 +2424,7 @@ func TestAssignCopilotToIssue(t *testing.T) { githubv4mock.NewQueryMatcher( struct { Repository struct { + ID githubv4.ID Issue struct { ID githubv4.ID Assignees struct { @@ -2485,6 +2442,7 @@ func TestAssignCopilotToIssue(t *testing.T) { }, githubv4mock.DataResponse(map[string]any{ "repository": map[string]any{ + "id": githubv4.ID("test-repo-id"), "issue": map[string]any{ "id": githubv4.ID("test-issue-id"), "assignees": map[string]any{ @@ -2496,25 +2454,43 @@ func TestAssignCopilotToIssue(t *testing.T) { ), githubv4mock.NewMutationMatcher( struct { - ReplaceActorsForAssignable struct { - Typename string `graphql:"__typename"` - } `graphql:"replaceActorsForAssignable(input: $input)"` + UpdateIssue struct { + Issue struct { + ID githubv4.ID + Number githubv4.Int + URL githubv4.String + } + } `graphql:"updateIssue(input: $input)"` }{}, - ReplaceActorsForAssignableInput{ - AssignableID: githubv4.ID("test-issue-id"), - ActorIDs: []githubv4.ID{githubv4.ID("copilot-swe-agent-id")}, + UpdateIssueInput{ + ID: githubv4.ID("test-issue-id"), + AssigneeIDs: []githubv4.ID{githubv4.ID("copilot-swe-agent-id")}, + AgentAssignment: &AgentAssignmentInput{ + BaseRef: nil, + CustomAgent: ptrGitHubv4String(""), + CustomInstructions: ptrGitHubv4String(""), + TargetRepositoryID: githubv4.ID("test-repo-id"), + }, }, nil, - githubv4mock.DataResponse(map[string]any{}), + githubv4mock.DataResponse(map[string]any{ + "updateIssue": map[string]any{ + "issue": map[string]any{ + "id": githubv4.ID("test-issue-id"), + "number": githubv4.Int(123), + "url": githubv4.String("https://github.com/owner/repo/issues/123"), + }, + }, + }), ), ), }, { name: "copilot not a suggested actor", requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "issueNumber": float64(123), + "owner": "owner", + "repo": "repo", + "issue_number": float64(123), }, mockedClient: githubv4mock.NewMockedHTTPClient( githubv4mock.NewQueryMatcher( @@ -2552,6 +2528,116 @@ func TestAssignCopilotToIssue(t *testing.T) { expectToolError: true, expectedToolErrMsg: "copilot isn't available as an assignee for this issue. Please inform the user to visit https://docs.github.com/en/copilot/using-github-copilot/using-copilot-coding-agent-to-work-on-tasks/about-assigning-tasks-to-copilot for more information.", }, + { + name: "successful assignment with base_ref specified", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(123), + "base_ref": "feature-branch", + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + SuggestedActors struct { + Nodes []struct { + Bot struct { + ID githubv4.ID + Login githubv4.String + TypeName string `graphql:"__typename"` + } `graphql:"... on Bot"` + } + PageInfo struct { + HasNextPage bool + EndCursor string + } + } `graphql:"suggestedActors(first: 100, after: $endCursor, capabilities: CAN_BE_ASSIGNED)"` + } `graphql:"repository(owner: $owner, name: $name)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "name": githubv4.String("repo"), + "endCursor": (*githubv4.String)(nil), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "suggestedActors": map[string]any{ + "nodes": []any{ + map[string]any{ + "id": githubv4.ID("copilot-swe-agent-id"), + "login": githubv4.String("copilot-swe-agent"), + "__typename": "Bot", + }, + }, + }, + }, + }), + ), + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + ID githubv4.ID + Issue struct { + ID githubv4.ID + Assignees struct { + Nodes []struct { + ID githubv4.ID + } + } `graphql:"assignees(first: 100)"` + } `graphql:"issue(number: $number)"` + } `graphql:"repository(owner: $owner, name: $name)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "name": githubv4.String("repo"), + "number": githubv4.Int(123), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "id": githubv4.ID("test-repo-id"), + "issue": map[string]any{ + "id": githubv4.ID("test-issue-id"), + "assignees": map[string]any{ + "nodes": []any{}, + }, + }, + }, + }), + ), + githubv4mock.NewMutationMatcher( + struct { + UpdateIssue struct { + Issue struct { + ID githubv4.ID + Number githubv4.Int + URL githubv4.String + } + } `graphql:"updateIssue(input: $input)"` + }{}, + UpdateIssueInput{ + ID: githubv4.ID("test-issue-id"), + AssigneeIDs: []githubv4.ID{githubv4.ID("copilot-swe-agent-id")}, + AgentAssignment: &AgentAssignmentInput{ + BaseRef: ptrGitHubv4String("feature-branch"), + CustomAgent: ptrGitHubv4String(""), + CustomInstructions: ptrGitHubv4String(""), + TargetRepositoryID: githubv4.ID("test-repo-id"), + }, + }, + nil, + githubv4mock.DataResponse(map[string]any{ + "updateIssue": map[string]any{ + "issue": map[string]any{ + "id": githubv4.ID("test-issue-id"), + "number": githubv4.Int(123), + "url": githubv4.String("https://github.com/owner/repo/issues/123"), + }, + }, + }), + ), + ), + }, } for _, tc := range tests { @@ -2631,12 +2717,9 @@ func Test_AddSubIssue(t *testing.T) { }{ { name: "successful sub-issue addition with all parameters", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber, - mockResponse(t, http.StatusCreated, mockIssue), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusCreated, mockIssue), + }), requestArgs: map[string]interface{}{ "method": "add", "owner": "owner", @@ -2650,12 +2733,9 @@ func Test_AddSubIssue(t *testing.T) { }, { name: "successful sub-issue addition with minimal parameters", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber, - mockResponse(t, http.StatusCreated, mockIssue), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusCreated, mockIssue), + }), requestArgs: map[string]interface{}{ "method": "add", "owner": "owner", @@ -2668,12 +2748,9 @@ func Test_AddSubIssue(t *testing.T) { }, { name: "successful sub-issue addition with replace_parent false", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber, - mockResponse(t, http.StatusCreated, mockIssue), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusCreated, mockIssue), + }), requestArgs: map[string]interface{}{ "method": "add", "owner": "owner", @@ -2687,12 +2764,9 @@ func Test_AddSubIssue(t *testing.T) { }, { name: "parent issue not found", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber, - mockResponse(t, http.StatusNotFound, `{"message": "Parent issue not found"}`), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusNotFound, `{"message": "Parent issue not found"}`), + }), requestArgs: map[string]interface{}{ "method": "add", "owner": "owner", @@ -2705,12 +2779,9 @@ func Test_AddSubIssue(t *testing.T) { }, { name: "sub-issue not found", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber, - mockResponse(t, http.StatusNotFound, `{"message": "Sub-issue not found"}`), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusNotFound, `{"message": "Sub-issue not found"}`), + }), requestArgs: map[string]interface{}{ "method": "add", "owner": "owner", @@ -2723,12 +2794,9 @@ func Test_AddSubIssue(t *testing.T) { }, { name: "validation failed - sub-issue cannot be parent of itself", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber, - mockResponse(t, http.StatusUnprocessableEntity, `{"message": "Validation failed", "errors": [{"message": "Sub-issue cannot be a parent of itself"}]}`), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusUnprocessableEntity, `{"message": "Validation failed", "errors": [{"message": "Sub-issue cannot be a parent of itself"}]}`), + }), requestArgs: map[string]interface{}{ "method": "add", "owner": "owner", @@ -2741,12 +2809,9 @@ func Test_AddSubIssue(t *testing.T) { }, { name: "insufficient permissions", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber, - mockResponse(t, http.StatusForbidden, `{"message": "Must have write access to repository"}`), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusForbidden, `{"message": "Must have write access to repository"}`), + }), requestArgs: map[string]interface{}{ "method": "add", "owner": "owner", @@ -2759,9 +2824,7 @@ func Test_AddSubIssue(t *testing.T) { }, { name: "missing required parameter owner", - mockedClient: mock.NewMockedHTTPClient( - // No mocked requests needed since validation fails before HTTP call - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]interface{}{ "method": "add", "repo": "repo", @@ -2773,9 +2836,7 @@ func Test_AddSubIssue(t *testing.T) { }, { name: "missing required parameter sub_issue_id", - mockedClient: mock.NewMockedHTTPClient( - // No mocked requests needed since validation fails before HTTP call - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]interface{}{ "method": "add", "owner": "owner", @@ -2895,12 +2956,9 @@ func Test_GetSubIssues(t *testing.T) { }{ { name: "successful sub-issues listing with minimal parameters", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber, - mockSubIssues, - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockSubIssues), + }), requestArgs: map[string]interface{}{ "method": "get_sub_issues", "owner": "owner", @@ -2912,17 +2970,14 @@ func Test_GetSubIssues(t *testing.T) { }, { name: "successful sub-issues listing with pagination", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber, - expectQueryParams(t, map[string]string{ - "page": "2", - "per_page": "10", - }).andThen( - mockResponse(t, http.StatusOK, mockSubIssues), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber: expectQueryParams(t, map[string]string{ + "page": "2", + "per_page": "10", + }).andThen( + mockResponse(t, http.StatusOK, mockSubIssues), ), - ), + }), requestArgs: map[string]interface{}{ "method": "get_sub_issues", "owner": "owner", @@ -2936,12 +2991,9 @@ func Test_GetSubIssues(t *testing.T) { }, { name: "successful sub-issues listing with empty result", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber, - []*github.Issue{}, - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, []*github.Issue{}), + }), requestArgs: map[string]interface{}{ "method": "get_sub_issues", "owner": "owner", @@ -2953,12 +3005,9 @@ func Test_GetSubIssues(t *testing.T) { }, { name: "parent issue not found", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber, - mockResponse(t, http.StatusNotFound, `{"message": "Not Found"}`), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusNotFound, `{"message": "Not Found"}`), + }), requestArgs: map[string]interface{}{ "method": "get_sub_issues", "owner": "owner", @@ -2970,12 +3019,9 @@ func Test_GetSubIssues(t *testing.T) { }, { name: "repository not found", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber, - mockResponse(t, http.StatusNotFound, `{"message": "Not Found"}`), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusNotFound, `{"message": "Not Found"}`), + }), requestArgs: map[string]interface{}{ "method": "get_sub_issues", "owner": "nonexistent", @@ -2987,12 +3033,9 @@ func Test_GetSubIssues(t *testing.T) { }, { name: "sub-issues feature gone/deprecated", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber, - mockResponse(t, http.StatusGone, `{"message": "This feature has been deprecated"}`), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusGone, `{"message": "This feature has been deprecated"}`), + }), requestArgs: map[string]interface{}{ "method": "get_sub_issues", "owner": "owner", @@ -3004,9 +3047,7 @@ func Test_GetSubIssues(t *testing.T) { }, { name: "missing required parameter owner", - mockedClient: mock.NewMockedHTTPClient( - // No mocked requests needed since validation fails before HTTP call - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]interface{}{ "method": "get_sub_issues", "repo": "repo", @@ -3017,9 +3058,7 @@ func Test_GetSubIssues(t *testing.T) { }, { name: "missing required parameter issue_number", - mockedClient: mock.NewMockedHTTPClient( - // No mocked requests needed since validation fails before HTTP call - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]interface{}{ "method": "get_sub_issues", "owner": "owner", @@ -3135,12 +3174,9 @@ func Test_RemoveSubIssue(t *testing.T) { }{ { name: "successful sub-issue removal", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber, - mockResponse(t, http.StatusOK, mockIssue), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockIssue), + }), requestArgs: map[string]interface{}{ "method": "remove", "owner": "owner", @@ -3153,12 +3189,9 @@ func Test_RemoveSubIssue(t *testing.T) { }, { name: "parent issue not found", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber, - mockResponse(t, http.StatusNotFound, `{"message": "Not Found"}`), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusNotFound, `{"message": "Not Found"}`), + }), requestArgs: map[string]interface{}{ "method": "remove", "owner": "owner", @@ -3171,12 +3204,9 @@ func Test_RemoveSubIssue(t *testing.T) { }, { name: "sub-issue not found", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber, - mockResponse(t, http.StatusNotFound, `{"message": "Sub-issue not found"}`), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusNotFound, `{"message": "Sub-issue not found"}`), + }), requestArgs: map[string]interface{}{ "method": "remove", "owner": "owner", @@ -3189,12 +3219,9 @@ func Test_RemoveSubIssue(t *testing.T) { }, { name: "bad request - invalid sub_issue_id", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber, - mockResponse(t, http.StatusBadRequest, `{"message": "Invalid sub_issue_id"}`), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusBadRequest, `{"message": "Invalid sub_issue_id"}`), + }), requestArgs: map[string]interface{}{ "method": "remove", "owner": "owner", @@ -3207,12 +3234,9 @@ func Test_RemoveSubIssue(t *testing.T) { }, { name: "repository not found", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber, - mockResponse(t, http.StatusNotFound, `{"message": "Not Found"}`), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusNotFound, `{"message": "Not Found"}`), + }), requestArgs: map[string]interface{}{ "method": "remove", "owner": "nonexistent", @@ -3225,12 +3249,9 @@ func Test_RemoveSubIssue(t *testing.T) { }, { name: "insufficient permissions", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber, - mockResponse(t, http.StatusForbidden, `{"message": "Must have write access to repository"}`), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusForbidden, `{"message": "Must have write access to repository"}`), + }), requestArgs: map[string]interface{}{ "method": "remove", "owner": "owner", @@ -3243,9 +3264,7 @@ func Test_RemoveSubIssue(t *testing.T) { }, { name: "missing required parameter owner", - mockedClient: mock.NewMockedHTTPClient( - // No mocked requests needed since validation fails before HTTP call - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]interface{}{ "method": "remove", "repo": "repo", @@ -3257,9 +3276,7 @@ func Test_RemoveSubIssue(t *testing.T) { }, { name: "missing required parameter sub_issue_id", - mockedClient: mock.NewMockedHTTPClient( - // No mocked requests needed since validation fails before HTTP call - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]interface{}{ "method": "remove", "owner": "owner", @@ -3365,12 +3382,9 @@ func Test_ReprioritizeSubIssue(t *testing.T) { }{ { name: "successful reprioritization with after_id", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber, - mockResponse(t, http.StatusOK, mockIssue), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockIssue), + }), requestArgs: map[string]interface{}{ "method": "reprioritize", "owner": "owner", @@ -3384,12 +3398,9 @@ func Test_ReprioritizeSubIssue(t *testing.T) { }, { name: "successful reprioritization with before_id", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber, - mockResponse(t, http.StatusOK, mockIssue), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockIssue), + }), requestArgs: map[string]interface{}{ "method": "reprioritize", "owner": "owner", @@ -3403,9 +3414,7 @@ func Test_ReprioritizeSubIssue(t *testing.T) { }, { name: "validation error - neither after_id nor before_id specified", - mockedClient: mock.NewMockedHTTPClient( - // No mocked requests needed since validation fails before HTTP call - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]interface{}{ "method": "reprioritize", "owner": "owner", @@ -3418,9 +3427,7 @@ func Test_ReprioritizeSubIssue(t *testing.T) { }, { name: "validation error - both after_id and before_id specified", - mockedClient: mock.NewMockedHTTPClient( - // No mocked requests needed since validation fails before HTTP call - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]interface{}{ "method": "reprioritize", "owner": "owner", @@ -3435,12 +3442,9 @@ func Test_ReprioritizeSubIssue(t *testing.T) { }, { name: "parent issue not found", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber, - mockResponse(t, http.StatusNotFound, `{"message": "Not Found"}`), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusNotFound, `{"message": "Not Found"}`), + }), requestArgs: map[string]interface{}{ "method": "reprioritize", "owner": "owner", @@ -3454,12 +3458,9 @@ func Test_ReprioritizeSubIssue(t *testing.T) { }, { name: "sub-issue not found", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber, - mockResponse(t, http.StatusNotFound, `{"message": "Sub-issue not found"}`), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusNotFound, `{"message": "Sub-issue not found"}`), + }), requestArgs: map[string]interface{}{ "method": "reprioritize", "owner": "owner", @@ -3473,12 +3474,9 @@ func Test_ReprioritizeSubIssue(t *testing.T) { }, { name: "validation failed - positioning sub-issue not found", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber, - mockResponse(t, http.StatusUnprocessableEntity, `{"message": "Validation failed", "errors": [{"message": "Positioning sub-issue not found"}]}`), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusUnprocessableEntity, `{"message": "Validation failed", "errors": [{"message": "Positioning sub-issue not found"}]}`), + }), requestArgs: map[string]interface{}{ "method": "reprioritize", "owner": "owner", @@ -3492,12 +3490,9 @@ func Test_ReprioritizeSubIssue(t *testing.T) { }, { name: "insufficient permissions", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber, - mockResponse(t, http.StatusForbidden, `{"message": "Must have write access to repository"}`), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusForbidden, `{"message": "Must have write access to repository"}`), + }), requestArgs: map[string]interface{}{ "method": "reprioritize", "owner": "owner", @@ -3511,12 +3506,9 @@ func Test_ReprioritizeSubIssue(t *testing.T) { }, { name: "service unavailable", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber, - mockResponse(t, http.StatusServiceUnavailable, `{"message": "Service Unavailable"}`), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusServiceUnavailable, `{"message": "Service Unavailable"}`), + }), requestArgs: map[string]interface{}{ "method": "reprioritize", "owner": "owner", @@ -3530,9 +3522,7 @@ func Test_ReprioritizeSubIssue(t *testing.T) { }, { name: "missing required parameter owner", - mockedClient: mock.NewMockedHTTPClient( - // No mocked requests needed since validation fails before HTTP call - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]interface{}{ "method": "reprioritize", "repo": "repo", @@ -3545,9 +3535,7 @@ func Test_ReprioritizeSubIssue(t *testing.T) { }, { name: "missing required parameter sub_issue_id", - mockedClient: mock.NewMockedHTTPClient( - // No mocked requests needed since validation fails before HTTP call - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]interface{}{ "method": "reprioritize", "owner": "owner", @@ -3645,15 +3633,9 @@ func Test_ListIssueTypes(t *testing.T) { }{ { name: "successful issue types retrieval", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{ - Pattern: "/orgs/testorg/issue-types", - Method: "GET", - }, - mockResponse(t, http.StatusOK, mockIssueTypes), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "GET /orgs/testorg/issue-types": mockResponse(t, http.StatusOK, mockIssueTypes), + }), requestArgs: map[string]interface{}{ "owner": "testorg", }, @@ -3662,15 +3644,9 @@ func Test_ListIssueTypes(t *testing.T) { }, { name: "organization not found", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{ - Pattern: "/orgs/nonexistent/issue-types", - Method: "GET", - }, - mockResponse(t, http.StatusNotFound, `{"message": "Organization not found"}`), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "GET /orgs/nonexistent/issue-types": mockResponse(t, http.StatusNotFound, `{"message": "Organization not found"}`), + }), requestArgs: map[string]interface{}{ "owner": "nonexistent", }, @@ -3679,15 +3655,9 @@ func Test_ListIssueTypes(t *testing.T) { }, { name: "missing owner parameter", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{ - Pattern: "/orgs/testorg/issue-types", - Method: "GET", - }, - mockResponse(t, http.StatusOK, mockIssueTypes), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "GET /orgs/testorg/issue-types": mockResponse(t, http.StatusOK, mockIssueTypes), + }), requestArgs: map[string]interface{}{}, expectError: false, // This should be handled by parameter validation, error returned in result expectedErrMsg: "missing required parameter: owner", diff --git a/pkg/github/labels.go b/pkg/github/labels.go index 2811cf66e..0dbb622d9 100644 --- a/pkg/github/labels.go +++ b/pkg/github/labels.go @@ -8,6 +8,7 @@ import ( ghErrors "github.com/github/github-mcp-server/pkg/errors" "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" "github.com/google/jsonschema-go/jsonschema" @@ -45,6 +46,7 @@ func GetLabel(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"owner", "repo", "name"}, }, }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { @@ -142,6 +144,7 @@ func ListLabels(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"owner", "repo"}, }, }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { @@ -253,6 +256,7 @@ func LabelWrite(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"method", "owner", "repo", "name"}, }, }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { // Get and validate required parameters method, err := RequiredParam[string](args, "method") diff --git a/pkg/github/notifications.go b/pkg/github/notifications.go index 1e2011fa3..1de24fb0d 100644 --- a/pkg/github/notifications.go +++ b/pkg/github/notifications.go @@ -11,6 +11,7 @@ import ( ghErrors "github.com/github/github-mcp-server/pkg/errors" "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" "github.com/google/go-github/v79/github" @@ -62,6 +63,7 @@ func ListNotifications(t translations.TranslationHelperFunc) inventory.ServerToo }, }), }, + []scopes.Scope{scopes.Notifications}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { client, err := deps.GetClient(ctx) if err != nil { @@ -187,6 +189,7 @@ func DismissNotification(t translations.TranslationHelperFunc) inventory.ServerT Required: []string{"threadID", "state"}, }, }, + []scopes.Scope{scopes.Notifications}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { client, err := deps.GetClient(ctx) if err != nil { @@ -270,6 +273,7 @@ func MarkAllNotificationsRead(t translations.TranslationHelperFunc) inventory.Se }, }, }, + []scopes.Scope{scopes.Notifications}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { client, err := deps.GetClient(ctx) if err != nil { @@ -354,6 +358,7 @@ func GetNotificationDetails(t translations.TranslationHelperFunc) inventory.Serv Required: []string{"notificationID"}, }, }, + []scopes.Scope{scopes.Notifications}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { client, err := deps.GetClient(ctx) if err != nil { @@ -427,6 +432,7 @@ func ManageNotificationSubscription(t translations.TranslationHelperFunc) invent Required: []string{"notificationID", "action"}, }, }, + []scopes.Scope{scopes.Notifications}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { client, err := deps.GetClient(ctx) if err != nil { @@ -526,6 +532,7 @@ func ManageRepositoryNotificationSubscription(t translations.TranslationHelperFu Required: []string{"owner", "repo", "action"}, }, }, + []scopes.Scope{scopes.Notifications}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { client, err := deps.GetClient(ctx) if err != nil { diff --git a/pkg/github/projects.go b/pkg/github/projects.go index 0536bed99..8af181a72 100644 --- a/pkg/github/projects.go +++ b/pkg/github/projects.go @@ -10,6 +10,7 @@ import ( ghErrors "github.com/github/github-mcp-server/pkg/errors" "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" "github.com/google/go-github/v79/github" @@ -25,8 +26,25 @@ const ( MaxProjectsPerPage = 50 ) +// FeatureFlagConsolidatedProjects is the feature flag that disables individual project tools +// in favor of the consolidated project tools. +const FeatureFlagConsolidatedProjects = "remote_mcp_consolidated_projects" + +// Method constants for consolidated project tools +const ( + projectsMethodListProjects = "list_projects" + projectsMethodListProjectFields = "list_project_fields" + projectsMethodListProjectItems = "list_project_items" + projectsMethodGetProject = "get_project" + projectsMethodGetProjectField = "get_project_field" + projectsMethodGetProjectItem = "get_project_item" + projectsMethodAddProjectItem = "add_project_item" + projectsMethodUpdateProjectItem = "update_project_item" + projectsMethodDeleteProjectItem = "delete_project_item" +) + func ListProjects(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + tool := NewTool( ToolsetMetadataProjects, mcp.Tool{ Name: "list_projects", @@ -67,6 +85,7 @@ func ListProjects(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"owner_type", "owner"}, }, }, + []scopes.Scope{scopes.ReadProject}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") @@ -140,10 +159,12 @@ func ListProjects(t translations.TranslationHelperFunc) inventory.ServerTool { return utils.NewToolResultText(string(r)), nil, nil }, ) + tool.FeatureFlagDisable = FeatureFlagConsolidatedProjects + return tool } func GetProject(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + tool := NewTool( ToolsetMetadataProjects, mcp.Tool{ Name: "get_project", @@ -172,6 +193,7 @@ func GetProject(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"project_number", "owner_type", "owner"}, }, }, + []scopes.Scope{scopes.ReadProject}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { projectNumber, err := RequiredInt(args, "project_number") @@ -228,10 +250,12 @@ func GetProject(t translations.TranslationHelperFunc) inventory.ServerTool { return utils.NewToolResultText(string(r)), nil, nil }, ) + tool.FeatureFlagDisable = FeatureFlagConsolidatedProjects + return tool } func ListProjectFields(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + tool := NewTool( ToolsetMetadataProjects, mcp.Tool{ Name: "list_project_fields", @@ -272,6 +296,7 @@ func ListProjectFields(t translations.TranslationHelperFunc) inventory.ServerToo Required: []string{"owner_type", "owner", "project_number"}, }, }, + []scopes.Scope{scopes.ReadProject}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") @@ -334,10 +359,12 @@ func ListProjectFields(t translations.TranslationHelperFunc) inventory.ServerToo return utils.NewToolResultText(string(r)), nil, nil }, ) + tool.FeatureFlagDisable = FeatureFlagConsolidatedProjects + return tool } func GetProjectField(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + tool := NewTool( ToolsetMetadataProjects, mcp.Tool{ Name: "get_project_field", @@ -370,6 +397,7 @@ func GetProjectField(t translations.TranslationHelperFunc) inventory.ServerTool Required: []string{"owner_type", "owner", "project_number", "field_id"}, }, }, + []scopes.Scope{scopes.ReadProject}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") @@ -426,10 +454,12 @@ func GetProjectField(t translations.TranslationHelperFunc) inventory.ServerTool return utils.NewToolResultText(string(r)), nil, nil }, ) + tool.FeatureFlagDisable = FeatureFlagConsolidatedProjects + return tool } func ListProjectItems(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + tool := NewTool( ToolsetMetadataProjects, mcp.Tool{ Name: "list_project_items", @@ -481,6 +511,7 @@ func ListProjectItems(t translations.TranslationHelperFunc) inventory.ServerTool Required: []string{"owner_type", "owner", "project_number"}, }, }, + []scopes.Scope{scopes.ReadProject}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") @@ -562,10 +593,12 @@ func ListProjectItems(t translations.TranslationHelperFunc) inventory.ServerTool return utils.NewToolResultText(string(r)), nil, nil }, ) + tool.FeatureFlagDisable = FeatureFlagConsolidatedProjects + return tool } func GetProjectItem(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + tool := NewTool( ToolsetMetadataProjects, mcp.Tool{ Name: "get_project_item", @@ -605,6 +638,7 @@ func GetProjectItem(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"owner_type", "owner", "project_number", "item_id"}, }, }, + []scopes.Scope{scopes.ReadProject}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") @@ -668,10 +702,12 @@ func GetProjectItem(t translations.TranslationHelperFunc) inventory.ServerTool { return utils.NewToolResultText(string(r)), nil, nil }, ) + tool.FeatureFlagDisable = FeatureFlagConsolidatedProjects + return tool } func AddProjectItem(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + tool := NewTool( ToolsetMetadataProjects, mcp.Tool{ Name: "add_project_item", @@ -709,6 +745,7 @@ func AddProjectItem(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"owner_type", "owner", "project_number", "item_type", "item_id"}, }, }, + []scopes.Scope{scopes.Project}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") @@ -779,10 +816,12 @@ func AddProjectItem(t translations.TranslationHelperFunc) inventory.ServerTool { return utils.NewToolResultText(string(r)), nil, nil }, ) + tool.FeatureFlagDisable = FeatureFlagConsolidatedProjects + return tool } func UpdateProjectItem(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + tool := NewTool( ToolsetMetadataProjects, mcp.Tool{ Name: "update_project_item", @@ -819,6 +858,7 @@ func UpdateProjectItem(t translations.TranslationHelperFunc) inventory.ServerToo Required: []string{"owner_type", "owner", "project_number", "item_id", "updated_field"}, }, }, + []scopes.Scope{scopes.Project}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") @@ -891,10 +931,12 @@ func UpdateProjectItem(t translations.TranslationHelperFunc) inventory.ServerToo return utils.NewToolResultText(string(r)), nil, nil }, ) + tool.FeatureFlagDisable = FeatureFlagConsolidatedProjects + return tool } func DeleteProjectItem(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + tool := NewTool( ToolsetMetadataProjects, mcp.Tool{ Name: "delete_project_item", @@ -928,6 +970,7 @@ func DeleteProjectItem(t translations.TranslationHelperFunc) inventory.ServerToo Required: []string{"owner_type", "owner", "project_number", "item_id"}, }, }, + []scopes.Scope{scopes.Project}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") @@ -977,6 +1020,741 @@ func DeleteProjectItem(t translations.TranslationHelperFunc) inventory.ServerToo return utils.NewToolResultText("project item successfully deleted"), nil, nil }, ) + tool.FeatureFlagDisable = FeatureFlagConsolidatedProjects + return tool +} + +// ProjectsList returns the tool and handler for listing GitHub Projects resources. +func ProjectsList(t translations.TranslationHelperFunc) inventory.ServerTool { + tool := NewTool( + ToolsetMetadataProjects, + mcp.Tool{ + Name: "projects_list", + Description: t("TOOL_PROJECTS_LIST_DESCRIPTION", + `Tools for listing GitHub Projects resources. +Use this tool to list projects for a user or organization, or list project fields and items for a specific project. +`), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_PROJECTS_LIST_USER_TITLE", "List GitHub Projects resources"), + ReadOnlyHint: true, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "method": { + Type: "string", + Description: "The action to perform", + Enum: []any{ + projectsMethodListProjects, + projectsMethodListProjectFields, + projectsMethodListProjectItems, + }, + }, + "owner_type": { + Type: "string", + Description: "Owner type", + 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.", + }, + "project_number": { + Type: "number", + Description: "The project's number. Required for 'list_project_fields' and 'list_project_items' methods.", + }, + "query": { + Type: "string", + Description: `Filter/query string. For list_projects: filter by title text and state (e.g. "roadmap is:open"). For list_project_items: advanced filtering using GitHub's project filtering syntax.`, + }, + "fields": { + Type: "array", + Description: "Field IDs to include when listing project items (e.g. [\"102589\", \"985201\"]). CRITICAL: Always provide to get field values. Without this, only titles returned. Only used for 'list_project_items' method.", + Items: &jsonschema.Schema{ + Type: "string", + }, + }, + "per_page": { + Type: "number", + Description: fmt.Sprintf("Results per page (max %d)", MaxProjectsPerPage), + }, + "after": { + Type: "string", + Description: "Forward pagination cursor from previous pageInfo.nextCursor.", + }, + "before": { + Type: "string", + Description: "Backward pagination cursor from previous pageInfo.prevCursor (rare).", + }, + }, + Required: []string{"method", "owner_type", "owner"}, + }, + }, + []scopes.Scope{scopes.ReadProject}, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + method, err := RequiredParam[string](args, "method") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + ownerType, err := RequiredParam[string](args, "owner_type") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + client, err := deps.GetClient(ctx) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + switch method { + case projectsMethodListProjects: + return listProjects(ctx, client, args, owner, ownerType) + case projectsMethodListProjectFields: + return listProjectFields(ctx, client, args, owner, ownerType) + case projectsMethodListProjectItems: + return listProjectItems(ctx, client, args, owner, ownerType) + default: + return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil + } + }, + ) + tool.FeatureFlagEnable = FeatureFlagConsolidatedProjects + return tool +} + +// ProjectsGet returns the tool and handler for getting GitHub Projects resources. +func ProjectsGet(t translations.TranslationHelperFunc) inventory.ServerTool { + tool := NewTool( + ToolsetMetadataProjects, + mcp.Tool{ + Name: "projects_get", + Description: t("TOOL_PROJECTS_GET_DESCRIPTION", `Get details about specific GitHub Projects resources. +Use this tool to get details about individual projects, project fields, and project items by their unique IDs. +`), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_PROJECTS_GET_USER_TITLE", "Get details of GitHub Projects resources"), + ReadOnlyHint: true, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "method": { + Type: "string", + Description: "The method to execute", + Enum: []any{ + projectsMethodGetProject, + projectsMethodGetProjectField, + projectsMethodGetProjectItem, + }, + }, + "owner_type": { + Type: "string", + Description: "Owner type", + 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.", + }, + "project_number": { + Type: "number", + Description: "The project's number.", + }, + "field_id": { + Type: "number", + Description: "The field's ID. Required for 'get_project_field' method.", + }, + "item_id": { + Type: "number", + Description: "The item's ID. Required for 'get_project_item' method.", + }, + "fields": { + Type: "array", + Description: "Specific list of field IDs to include in the response when getting a project item (e.g. [\"102589\", \"985201\", \"169875\"]). If not provided, only the title field is included. Only used for 'get_project_item' method.", + Items: &jsonschema.Schema{ + Type: "string", + }, + }, + }, + Required: []string{"method", "owner_type", "owner", "project_number"}, + }, + }, + []scopes.Scope{scopes.ReadProject}, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + method, err := RequiredParam[string](args, "method") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + ownerType, err := RequiredParam[string](args, "owner_type") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + projectNumber, err := RequiredInt(args, "project_number") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + client, err := deps.GetClient(ctx) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + switch method { + case projectsMethodGetProject: + return getProject(ctx, client, owner, ownerType, projectNumber) + case projectsMethodGetProjectField: + fieldID, err := RequiredBigInt(args, "field_id") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + return getProjectField(ctx, client, owner, ownerType, projectNumber, fieldID) + case projectsMethodGetProjectItem: + itemID, err := RequiredBigInt(args, "item_id") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + fields, err := OptionalBigIntArrayParam(args, "fields") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + return getProjectItem(ctx, client, owner, ownerType, projectNumber, itemID, fields) + default: + return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil + } + }, + ) + tool.FeatureFlagEnable = FeatureFlagConsolidatedProjects + return tool +} + +// ProjectsWrite returns the tool and handler for modifying GitHub Projects resources. +func ProjectsWrite(t translations.TranslationHelperFunc) inventory.ServerTool { + tool := NewTool( + ToolsetMetadataProjects, + mcp.Tool{ + Name: "projects_write", + Description: t("TOOL_PROJECTS_WRITE_DESCRIPTION", "Add, update, or delete project items in a GitHub Project."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_PROJECTS_WRITE_USER_TITLE", "Modify GitHub Project items"), + ReadOnlyHint: false, + DestructiveHint: jsonschema.Ptr(true), + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "method": { + Type: "string", + Description: "The method to execute", + Enum: []any{ + projectsMethodAddProjectItem, + projectsMethodUpdateProjectItem, + projectsMethodDeleteProjectItem, + }, + }, + "owner_type": { + Type: "string", + Description: "Owner type", + 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.", + }, + "project_number": { + Type: "number", + Description: "The project's number.", + }, + "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.", + }, + "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"}, + }, + "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"}, + }, + }, + []scopes.Scope{scopes.Project}, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + method, err := RequiredParam[string](args, "method") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + ownerType, err := RequiredParam[string](args, "owner_type") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + projectNumber, err := RequiredInt(args, "project_number") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + client, err := deps.GetClient(ctx) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + switch method { + case projectsMethodAddProjectItem: + itemID, err := RequiredBigInt(args, "item_id") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + itemType, err := RequiredParam[string](args, "item_type") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + return addProjectItem(ctx, client, owner, ownerType, projectNumber, itemID, itemType) + case projectsMethodUpdateProjectItem: + itemID, err := RequiredBigInt(args, "item_id") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + rawUpdatedField, exists := args["updated_field"] + if !exists { + return utils.NewToolResultError("missing required parameter: updated_field"), nil, nil + } + fieldValue, ok := rawUpdatedField.(map[string]any) + if !ok || fieldValue == nil { + return utils.NewToolResultError("updated_field must be an object"), nil, nil + } + return updateProjectItem(ctx, client, owner, ownerType, projectNumber, itemID, fieldValue) + case projectsMethodDeleteProjectItem: + itemID, err := RequiredBigInt(args, "item_id") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + return deleteProjectItem(ctx, client, owner, ownerType, projectNumber, itemID) + default: + return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil + } + }, + ) + tool.FeatureFlagEnable = FeatureFlagConsolidatedProjects + return tool +} + +// Helper functions for consolidated projects tools + +func listProjects(ctx context.Context, client *github.Client, args map[string]any, owner, ownerType string) (*mcp.CallToolResult, any, error) { + queryStr, err := OptionalParam[string](args, "query") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + pagination, err := extractPaginationOptionsFromArgs(args) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + var resp *github.Response + var projects []*github.ProjectV2 + var queryPtr *string + + if queryStr != "" { + queryPtr = &queryStr + } + + minimalProjects := []MinimalProject{} + opts := &github.ListProjectsOptions{ + ListProjectsPaginationOptions: pagination, + Query: queryPtr, + } + + if ownerType == "org" { + projects, resp, err = client.Projects.ListOrganizationProjects(ctx, owner, opts) + } else { + projects, resp, err = client.Projects.ListUserProjects(ctx, owner, opts) + } + + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to list projects", + resp, + err, + ), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + for _, project := range projects { + minimalProjects = append(minimalProjects, *convertToMinimalProject(project)) + } + + 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 +} + +func listProjectFields(ctx context.Context, client *github.Client, args map[string]any, owner, ownerType string) (*mcp.CallToolResult, any, error) { + projectNumber, err := RequiredInt(args, "project_number") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + pagination, err := extractPaginationOptionsFromArgs(args) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + var resp *github.Response + var projectFields []*github.ProjectV2Field + + opts := &github.ListProjectsOptions{ + ListProjectsPaginationOptions: pagination, + } + + if ownerType == "org" { + projectFields, resp, err = client.Projects.ListOrganizationProjectFields(ctx, owner, projectNumber, opts) + } else { + projectFields, resp, err = client.Projects.ListUserProjectFields(ctx, owner, projectNumber, opts) + } + + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to list project fields", + resp, + err, + ), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + response := map[string]any{ + "fields": projectFields, + "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 +} + +func listProjectItems(ctx context.Context, client *github.Client, args map[string]any, owner, ownerType string) (*mcp.CallToolResult, any, error) { + projectNumber, err := RequiredInt(args, "project_number") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + queryStr, err := OptionalParam[string](args, "query") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + fields, err := OptionalBigIntArrayParam(args, "fields") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + pagination, err := extractPaginationOptionsFromArgs(args) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + var resp *github.Response + var projectItems []*github.ProjectV2Item + var queryPtr *string + + if queryStr != "" { + queryPtr = &queryStr + } + + opts := &github.ListProjectItemsOptions{ + Fields: fields, + ListProjectsOptions: github.ListProjectsOptions{ + ListProjectsPaginationOptions: pagination, + Query: queryPtr, + }, + } + + if ownerType == "org" { + projectItems, resp, err = client.Projects.ListOrganizationProjectItems(ctx, owner, projectNumber, opts) + } else { + projectItems, resp, err = client.Projects.ListUserProjectItems(ctx, owner, projectNumber, opts) + } + + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + ProjectListFailedError, + resp, + err, + ), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + response := map[string]any{ + "items": projectItems, + "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 +} + +func getProject(ctx context.Context, client *github.Client, owner, ownerType string, projectNumber int) (*mcp.CallToolResult, any, error) { + var resp *github.Response + var project *github.ProjectV2 + var err error + + if ownerType == "org" { + project, resp, err = client.Projects.GetOrganizationProject(ctx, owner, projectNumber) + } else { + project, resp, err = client.Projects.GetUserProject(ctx, owner, projectNumber) + } + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to get project", + resp, + err, + ), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + 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, "failed to get project", resp, body), nil, nil + } + + minimalProject := convertToMinimalProject(project) + r, err := json.Marshal(minimalProject) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return utils.NewToolResultText(string(r)), nil, nil +} + +func getProjectField(ctx context.Context, client *github.Client, owner, ownerType string, projectNumber int, fieldID int64) (*mcp.CallToolResult, any, error) { + var resp *github.Response + var projectField *github.ProjectV2Field + var err error + + if ownerType == "org" { + projectField, resp, err = client.Projects.GetOrganizationProjectField(ctx, owner, projectNumber, fieldID) + } else { + projectField, resp, err = client.Projects.GetUserProjectField(ctx, owner, projectNumber, fieldID) + } + + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to get project field", + resp, + err, + ), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + 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, "failed to get project field", resp, body), nil, nil + } + r, err := json.Marshal(projectField) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return utils.NewToolResultText(string(r)), nil, nil +} + +func getProjectItem(ctx context.Context, client *github.Client, owner, ownerType string, projectNumber int, itemID int64, fields []int64) (*mcp.CallToolResult, any, error) { + var resp *github.Response + var projectItem *github.ProjectV2Item + var opts *github.GetProjectItemOptions + var err error + + if len(fields) > 0 { + opts = &github.GetProjectItemOptions{ + Fields: fields, + } + } + + if ownerType == "org" { + projectItem, resp, err = client.Projects.GetOrganizationProjectItem(ctx, owner, projectNumber, itemID, opts) + } else { + projectItem, resp, err = client.Projects.GetUserProjectItem(ctx, owner, projectNumber, itemID, opts) + } + + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to get project item", + resp, + err, + ), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + 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, "failed to get project item", resp, body), nil, nil + } + + r, err := json.Marshal(projectItem) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) + } + + 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 { + return utils.NewToolResultError(err.Error()), nil, nil + } + + var resp *github.Response + var updatedItem *github.ProjectV2Item + + if ownerType == "org" { + updatedItem, resp, err = client.Projects.UpdateOrganizationProjectItem(ctx, owner, projectNumber, itemID, updatePayload) + } else { + updatedItem, resp, err = client.Projects.UpdateUserProjectItem(ctx, owner, projectNumber, itemID, updatePayload) + } + + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + ProjectUpdateFailedError, + resp, + err, + ), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + 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, ProjectUpdateFailedError, resp, body), nil, nil + } + r, err := json.Marshal(updatedItem) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return utils.NewToolResultText(string(r)), nil, nil +} + +func deleteProjectItem(ctx context.Context, client *github.Client, owner, ownerType string, projectNumber int, itemID int64) (*mcp.CallToolResult, any, error) { + var resp *github.Response + var err error + + if ownerType == "org" { + resp, err = client.Projects.DeleteOrganizationProjectItem(ctx, owner, projectNumber, itemID) + } else { + resp, err = client.Projects.DeleteUserProjectItem(ctx, owner, projectNumber, itemID) + } + + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + ProjectDeleteFailedError, + resp, + err, + ), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusNoContent { + 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, ProjectDeleteFailedError, resp, body), nil, nil + } + return utils.NewToolResultText("project item successfully deleted"), nil, nil } type pageInfo struct { diff --git a/pkg/github/projects_test.go b/pkg/github/projects_test.go index e443b9ecd..9819e7d7e 100644 --- a/pkg/github/projects_test.go +++ b/pkg/github/projects_test.go @@ -3,7 +3,6 @@ package github import ( "context" "encoding/json" - "io" "net/http" "testing" @@ -11,7 +10,6 @@ import ( "github.com/github/github-mcp-server/pkg/translations" gh "github.com/google/go-github/v79/github" "github.com/google/jsonschema-go/jsonschema" - "github.com/migueleliasweb/go-github-mock/src/mock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -45,15 +43,9 @@ func Test_ListProjects(t *testing.T) { }{ { name: "success organization", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2", Method: http.MethodGet}, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write(mock.MustMarshal(orgProjects)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetOrgsProjectsV2: mockResponse(t, http.StatusOK, orgProjects), + }), requestArgs: map[string]interface{}{ "owner": "octo-org", "owner_type": "org", @@ -63,15 +55,9 @@ func Test_ListProjects(t *testing.T) { }, { name: "success user", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/users/{username}/projectsV2", Method: http.MethodGet}, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write(mock.MustMarshal(userProjects)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetUsersProjectsV2ByUsername: mockResponse(t, http.StatusOK, userProjects), + }), requestArgs: map[string]interface{}{ "owner": "octocat", "owner_type": "user", @@ -81,21 +67,12 @@ func Test_ListProjects(t *testing.T) { }, { name: "success organization with pagination & query", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2", Method: http.MethodGet}, - http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - q := r.URL.Query() - if q.Get("per_page") == "50" && q.Get("q") == "roadmap" { - w.WriteHeader(http.StatusOK) - _, _ = w.Write(mock.MustMarshal(orgProjects)) - return - } - w.WriteHeader(http.StatusBadRequest) - _, _ = w.Write([]byte(`{"message":"unexpected query params"}`)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetOrgsProjectsV2: expectQueryParams(t, map[string]string{ + "per_page": "50", + "q": "roadmap", + }).andThen(mockResponse(t, http.StatusOK, orgProjects)), + }), requestArgs: map[string]interface{}{ "owner": "octo-org", "owner_type": "org", @@ -107,12 +84,9 @@ func Test_ListProjects(t *testing.T) { }, { name: "api error", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2", Method: http.MethodGet}, - mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetOrgsProjectsV2: mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), + }), requestArgs: map[string]interface{}{ "owner": "octo-org", "owner_type": "org", @@ -122,7 +96,7 @@ func Test_ListProjects(t *testing.T) { }, { name: "missing owner", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]interface{}{ "owner_type": "org", }, @@ -130,7 +104,7 @@ func Test_ListProjects(t *testing.T) { }, { name: "missing owner_type", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]interface{}{ "owner": "octo-org", }, @@ -204,12 +178,9 @@ func Test_GetProject(t *testing.T) { }{ { name: "success organization project fetch", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/123", Method: http.MethodGet}, - mockResponse(t, http.StatusOK, project), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetOrgsProjectsV2ByProject: mockResponse(t, http.StatusOK, project), + }), requestArgs: map[string]interface{}{ "project_number": float64(123), "owner": "octo-org", @@ -219,12 +190,9 @@ func Test_GetProject(t *testing.T) { }, { name: "success user project fetch", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/users/{username}/projectsV2/456", Method: http.MethodGet}, - mockResponse(t, http.StatusOK, project), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetUsersProjectsV2ByUsernameByProject: mockResponse(t, http.StatusOK, project), + }), requestArgs: map[string]interface{}{ "project_number": float64(456), "owner": "octocat", @@ -234,12 +202,9 @@ func Test_GetProject(t *testing.T) { }, { name: "api error", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/999", Method: http.MethodGet}, - mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetOrgsProjectsV2ByProject: mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), + }), requestArgs: map[string]interface{}{ "project_number": float64(999), "owner": "octo-org", @@ -250,7 +215,7 @@ func Test_GetProject(t *testing.T) { }, { name: "missing project_number", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]interface{}{ "owner": "octo-org", "owner_type": "org", @@ -259,7 +224,7 @@ func Test_GetProject(t *testing.T) { }, { name: "missing owner", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]interface{}{ "project_number": float64(123), "owner_type": "org", @@ -268,7 +233,7 @@ func Test_GetProject(t *testing.T) { }, { name: "missing owner_type", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]interface{}{ "project_number": float64(123), "owner": "octo-org", @@ -343,15 +308,9 @@ func Test_ListProjectFields(t *testing.T) { }{ { name: "success organization fields", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/fields", Method: http.MethodGet}, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write(mock.MustMarshal(orgFields)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetOrgsProjectsV2FieldsByProject: mockResponse(t, http.StatusOK, orgFields), + }), requestArgs: map[string]interface{}{ "owner": "octo-org", "owner_type": "org", @@ -361,21 +320,11 @@ func Test_ListProjectFields(t *testing.T) { }, { name: "success user fields with per_page override", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/users/{user}/projectsV2/{project}/fields", Method: http.MethodGet}, - http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - q := r.URL.Query() - if q.Get("per_page") == "50" { - w.WriteHeader(http.StatusOK) - _, _ = w.Write(mock.MustMarshal(userFields)) - return - } - w.WriteHeader(http.StatusBadRequest) - _, _ = w.Write([]byte(`{"message":"unexpected query params"}`)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetUsersProjectsV2FieldsByUsernameByProject: expectQueryParams(t, map[string]string{ + "per_page": "50", + }).andThen(mockResponse(t, http.StatusOK, userFields)), + }), requestArgs: map[string]interface{}{ "owner": "octocat", "owner_type": "user", @@ -386,12 +335,9 @@ func Test_ListProjectFields(t *testing.T) { }, { name: "api error", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/fields", Method: http.MethodGet}, - mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetOrgsProjectsV2FieldsByProject: mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), + }), requestArgs: map[string]interface{}{ "owner": "octo-org", "owner_type": "org", @@ -402,7 +348,7 @@ func Test_ListProjectFields(t *testing.T) { }, { name: "missing owner", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]interface{}{ "owner_type": "org", "project_number": 10, @@ -411,7 +357,7 @@ func Test_ListProjectFields(t *testing.T) { }, { name: "missing owner_type", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]interface{}{ "owner": "octo-org", "project_number": 10, @@ -420,7 +366,7 @@ func Test_ListProjectFields(t *testing.T) { }, { name: "missing project_number", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]interface{}{ "owner": "octo-org", "owner_type": "org", @@ -500,12 +446,9 @@ func Test_GetProjectField(t *testing.T) { }{ { name: "success organization field", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/fields/{field_id}", Method: http.MethodGet}, - mockResponse(t, http.StatusOK, orgField), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetOrgsProjectsV2FieldsByProjectByFieldID: mockResponse(t, http.StatusOK, orgField), + }), requestArgs: map[string]any{ "owner": "octo-org", "owner_type": "org", @@ -516,12 +459,9 @@ func Test_GetProjectField(t *testing.T) { }, { name: "success user field", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/users/{user}/projectsV2/{project}/fields/{field_id}", Method: http.MethodGet}, - mockResponse(t, http.StatusOK, userField), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetUsersProjectsV2FieldsByUsernameByProjectByFieldID: mockResponse(t, http.StatusOK, userField), + }), requestArgs: map[string]any{ "owner": "octocat", "owner_type": "user", @@ -532,12 +472,9 @@ func Test_GetProjectField(t *testing.T) { }, { name: "api error", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/fields/{field_id}", Method: http.MethodGet}, - mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetOrgsProjectsV2FieldsByProjectByFieldID: mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), + }), requestArgs: map[string]any{ "owner": "octo-org", "owner_type": "org", @@ -549,7 +486,7 @@ func Test_GetProjectField(t *testing.T) { }, { name: "missing owner", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]any{ "owner_type": "org", "project_number": float64(10), @@ -559,7 +496,7 @@ func Test_GetProjectField(t *testing.T) { }, { name: "missing owner_type", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]any{ "owner": "octo-org", "project_number": float64(10), @@ -569,7 +506,7 @@ func Test_GetProjectField(t *testing.T) { }, { name: "missing project_number", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]any{ "owner": "octo-org", "owner_type": "org", @@ -579,7 +516,7 @@ func Test_GetProjectField(t *testing.T) { }, { name: "missing field_id", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]any{ "owner": "octo-org", "owner_type": "org", @@ -671,12 +608,9 @@ func Test_ListProjectItems(t *testing.T) { }{ { name: "success organization items", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items", Method: http.MethodGet}, - mockResponse(t, http.StatusOK, orgItems), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetOrgsProjectsV2ItemsByProject: mockResponse(t, http.StatusOK, orgItems), + }), requestArgs: map[string]interface{}{ "owner": "octo-org", "owner_type": "org", @@ -686,21 +620,12 @@ func Test_ListProjectItems(t *testing.T) { }, { name: "success organization items with fields", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items", Method: http.MethodGet}, - http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - q := r.URL.Query() - if q.Get("fields") == "123,456,789" { - w.WriteHeader(http.StatusOK) - _, _ = w.Write(mock.MustMarshal(orgItems)) - return - } - w.WriteHeader(http.StatusBadRequest) - _, _ = w.Write([]byte(`{"message":"unexpected query params"}`)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetOrgsProjectsV2ItemsByProject: expectQueryParams(t, map[string]string{ + "fields": "123,456,789", + "per_page": "50", + }).andThen(mockResponse(t, http.StatusOK, orgItems)), + }), requestArgs: map[string]interface{}{ "owner": "octo-org", "owner_type": "org", @@ -711,12 +636,9 @@ func Test_ListProjectItems(t *testing.T) { }, { name: "success user items", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/users/{user}/projectsV2/{project}/items", Method: http.MethodGet}, - mockResponse(t, http.StatusOK, userItems), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetUsersProjectsV2ItemsByUsernameByProject: mockResponse(t, http.StatusOK, userItems), + }), requestArgs: map[string]interface{}{ "owner": "octocat", "owner_type": "user", @@ -726,21 +648,12 @@ func Test_ListProjectItems(t *testing.T) { }, { name: "success with pagination and query", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items", Method: http.MethodGet}, - http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - q := r.URL.Query() - if q.Get("per_page") == "50" && q.Get("q") == "bug" { - w.WriteHeader(http.StatusOK) - _, _ = w.Write(mock.MustMarshal(orgItems)) - return - } - w.WriteHeader(http.StatusBadRequest) - _, _ = w.Write([]byte(`{"message":"unexpected query params"}`)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetOrgsProjectsV2ItemsByProject: expectQueryParams(t, map[string]string{ + "per_page": "50", + "q": "bug", + }).andThen(mockResponse(t, http.StatusOK, orgItems)), + }), requestArgs: map[string]interface{}{ "owner": "octo-org", "owner_type": "org", @@ -752,12 +665,9 @@ func Test_ListProjectItems(t *testing.T) { }, { name: "api error", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items", Method: http.MethodGet}, - mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetOrgsProjectsV2ItemsByProject: mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), + }), requestArgs: map[string]interface{}{ "owner": "octo-org", "owner_type": "org", @@ -768,7 +678,7 @@ func Test_ListProjectItems(t *testing.T) { }, { name: "missing owner", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]interface{}{ "owner_type": "org", "project_number": float64(10), @@ -777,7 +687,7 @@ func Test_ListProjectItems(t *testing.T) { }, { name: "missing owner_type", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]interface{}{ "owner": "octo-org", "project_number": float64(10), @@ -786,7 +696,7 @@ func Test_ListProjectItems(t *testing.T) { }, { name: "missing project_number", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]interface{}{ "owner": "octo-org", "owner_type": "org", @@ -877,12 +787,9 @@ func Test_GetProjectItem(t *testing.T) { }{ { name: "success organization item", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items/{item_id}", Method: http.MethodGet}, - mockResponse(t, http.StatusOK, orgItem), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetOrgsProjectsV2ItemsByProjectByItemID: mockResponse(t, http.StatusOK, orgItem), + }), requestArgs: map[string]any{ "owner": "octo-org", "owner_type": "org", @@ -893,21 +800,11 @@ func Test_GetProjectItem(t *testing.T) { }, { name: "success organization item with fields", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items/{item_id}", Method: http.MethodGet}, - http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - q := r.URL.Query() - if q.Get("fields") == "123,456" { - w.WriteHeader(http.StatusOK) - _, _ = w.Write(mock.MustMarshal(orgItem)) - return - } - w.WriteHeader(http.StatusBadRequest) - _, _ = w.Write([]byte(`{"message":"unexpected query params"}`)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetOrgsProjectsV2ItemsByProjectByItemID: expectQueryParams(t, map[string]string{ + "fields": "123,456", + }).andThen(mockResponse(t, http.StatusOK, orgItem)), + }), requestArgs: map[string]any{ "owner": "octo-org", "owner_type": "org", @@ -919,12 +816,9 @@ func Test_GetProjectItem(t *testing.T) { }, { name: "success user item", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/users/{user}/projectsV2/{project}/items/{item_id}", Method: http.MethodGet}, - mockResponse(t, http.StatusOK, userItem), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetUsersProjectsV2ItemsByUsernameByProjectByItemID: mockResponse(t, http.StatusOK, userItem), + }), requestArgs: map[string]any{ "owner": "octocat", "owner_type": "user", @@ -935,12 +829,9 @@ func Test_GetProjectItem(t *testing.T) { }, { name: "api error", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items/{item_id}", Method: http.MethodGet}, - mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetOrgsProjectsV2ItemsByProjectByItemID: mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), + }), requestArgs: map[string]any{ "owner": "octo-org", "owner_type": "org", @@ -952,7 +843,7 @@ func Test_GetProjectItem(t *testing.T) { }, { name: "missing owner", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]any{ "owner_type": "org", "project_number": float64(10), @@ -962,7 +853,7 @@ func Test_GetProjectItem(t *testing.T) { }, { name: "missing owner_type", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]any{ "owner": "octo-org", "project_number": float64(10), @@ -972,7 +863,7 @@ func Test_GetProjectItem(t *testing.T) { }, { name: "missing project_number", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]any{ "owner": "octo-org", "owner_type": "org", @@ -982,7 +873,7 @@ func Test_GetProjectItem(t *testing.T) { }, { name: "missing item_id", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]any{ "owner": "octo-org", "owner_type": "org", @@ -1086,24 +977,12 @@ func Test_AddProjectItem(t *testing.T) { }{ { name: "success organization issue", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items", Method: http.MethodPost}, - http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - body, err := io.ReadAll(r.Body) - assert.NoError(t, err) - var payload struct { - Type string `json:"type"` - ID int `json:"id"` - } - assert.NoError(t, json.Unmarshal(body, &payload)) - assert.Equal(t, "Issue", payload.Type) - assert.Equal(t, 9876, payload.ID) - w.WriteHeader(http.StatusCreated) - _, _ = w.Write(mock.MustMarshal(orgItem)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostOrgsProjectsV2ItemsByProject: expectRequestBody(t, map[string]any{ + "type": "Issue", + "id": float64(9876), + }).andThen(mockResponse(t, http.StatusCreated, orgItem)), + }), requestArgs: map[string]any{ "owner": "octo-org", "owner_type": "org", @@ -1117,24 +996,12 @@ func Test_AddProjectItem(t *testing.T) { }, { name: "success user pull request", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/users/{user}/projectsV2/{project}/items", Method: http.MethodPost}, - http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - body, err := io.ReadAll(r.Body) - assert.NoError(t, err) - var payload struct { - Type string `json:"type"` - ID int `json:"id"` - } - assert.NoError(t, json.Unmarshal(body, &payload)) - assert.Equal(t, "PullRequest", payload.Type) - assert.Equal(t, 7654, payload.ID) - w.WriteHeader(http.StatusCreated) - _, _ = w.Write(mock.MustMarshal(userItem)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostUsersProjectsV2ItemsByUsernameByProject: expectRequestBody(t, map[string]any{ + "type": "PullRequest", + "id": float64(7654), + }).andThen(mockResponse(t, http.StatusCreated, userItem)), + }), requestArgs: map[string]any{ "owner": "octocat", "owner_type": "user", @@ -1148,12 +1015,9 @@ func Test_AddProjectItem(t *testing.T) { }, { name: "api error", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items", Method: http.MethodPost}, - mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostOrgsProjectsV2ItemsByProject: mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), + }), requestArgs: map[string]any{ "owner": "octo-org", "owner_type": "org", @@ -1166,7 +1030,7 @@ func Test_AddProjectItem(t *testing.T) { }, { name: "missing owner", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]any{ "owner_type": "org", "project_number": float64(1), @@ -1177,7 +1041,7 @@ func Test_AddProjectItem(t *testing.T) { }, { name: "missing owner_type", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]any{ "owner": "octo-org", "project_number": float64(1), @@ -1188,7 +1052,7 @@ func Test_AddProjectItem(t *testing.T) { }, { name: "missing project_number", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]any{ "owner": "octo-org", "owner_type": "org", @@ -1199,7 +1063,7 @@ func Test_AddProjectItem(t *testing.T) { }, { name: "missing item_type", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]any{ "owner": "octo-org", "owner_type": "org", @@ -1210,7 +1074,7 @@ func Test_AddProjectItem(t *testing.T) { }, { name: "missing item_id", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]any{ "owner": "octo-org", "owner_type": "org", @@ -1310,27 +1174,11 @@ func Test_UpdateProjectItem(t *testing.T) { }{ { name: "success organization update", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items/{item_id}", Method: http.MethodPatch}, - http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - body, err := io.ReadAll(r.Body) - assert.NoError(t, err) - var payload struct { - Fields []struct { - ID int `json:"id"` - Value interface{} `json:"value"` - } `json:"fields"` - } - assert.NoError(t, json.Unmarshal(body, &payload)) - require.Len(t, payload.Fields, 1) - assert.Equal(t, 101, payload.Fields[0].ID) - assert.Equal(t, "Done", payload.Fields[0].Value) - w.WriteHeader(http.StatusOK) - _, _ = w.Write(mock.MustMarshal(orgUpdatedItem)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchOrgsProjectsV2ItemsByProjectByItemID: expectRequestBody(t, map[string]any{ + "fields": []any{map[string]any{"id": float64(101), "value": "Done"}}, + }).andThen(mockResponse(t, http.StatusOK, orgUpdatedItem)), + }), requestArgs: map[string]any{ "owner": "octo-org", "owner_type": "org", @@ -1345,27 +1193,11 @@ func Test_UpdateProjectItem(t *testing.T) { }, { name: "success user update", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/users/{user}/projectsV2/{project}/items/{item_id}", Method: http.MethodPatch}, - http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - body, err := io.ReadAll(r.Body) - assert.NoError(t, err) - var payload struct { - Fields []struct { - ID int `json:"id"` - Value interface{} `json:"value"` - } `json:"fields"` - } - assert.NoError(t, json.Unmarshal(body, &payload)) - require.Len(t, payload.Fields, 1) - assert.Equal(t, 202, payload.Fields[0].ID) - assert.Equal(t, 42.0, payload.Fields[0].Value) - w.WriteHeader(http.StatusOK) - _, _ = w.Write(mock.MustMarshal(userUpdatedItem)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchUsersProjectsV2ItemsByUsernameByProjectByItemID: expectRequestBody(t, map[string]any{ + "fields": []any{map[string]any{"id": float64(202), "value": float64(42)}}, + }).andThen(mockResponse(t, http.StatusOK, userUpdatedItem)), + }), requestArgs: map[string]any{ "owner": "octocat", "owner_type": "user", @@ -1380,12 +1212,9 @@ func Test_UpdateProjectItem(t *testing.T) { }, { name: "api error", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items/{item_id}", Method: http.MethodPatch}, - mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchOrgsProjectsV2ItemsByProjectByItemID: mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), + }), requestArgs: map[string]any{ "owner": "octo-org", "owner_type": "org", @@ -1401,7 +1230,7 @@ func Test_UpdateProjectItem(t *testing.T) { }, { name: "missing owner", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]any{ "owner_type": "org", "project_number": float64(1), @@ -1415,7 +1244,7 @@ func Test_UpdateProjectItem(t *testing.T) { }, { name: "missing owner_type", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]any{ "owner": "octo-org", "project_number": float64(1), @@ -1429,7 +1258,7 @@ func Test_UpdateProjectItem(t *testing.T) { }, { name: "missing project_number", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]any{ "owner": "octo-org", "owner_type": "org", @@ -1443,7 +1272,7 @@ func Test_UpdateProjectItem(t *testing.T) { }, { name: "missing item_id", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]any{ "owner": "octo-org", "owner_type": "org", @@ -1457,7 +1286,7 @@ func Test_UpdateProjectItem(t *testing.T) { }, { name: "missing updated_field", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]any{ "owner": "octo-org", "owner_type": "org", @@ -1468,7 +1297,7 @@ func Test_UpdateProjectItem(t *testing.T) { }, { name: "updated_field not object", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]any{ "owner": "octo-org", "owner_type": "org", @@ -1480,7 +1309,7 @@ func Test_UpdateProjectItem(t *testing.T) { }, { name: "updated_field missing id", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]any{ "owner": "octo-org", "owner_type": "org", @@ -1492,7 +1321,7 @@ func Test_UpdateProjectItem(t *testing.T) { }, { name: "updated_field missing value", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]any{ "owner": "octo-org", "owner_type": "org", @@ -1580,14 +1409,11 @@ func Test_DeleteProjectItem(t *testing.T) { }{ { name: "success organization delete", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items/{item_id}", Method: http.MethodDelete}, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNoContent) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + DeleteOrgsProjectsV2ItemsByProjectByItemID: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + }), + }), requestArgs: map[string]any{ "owner": "octo-org", "owner_type": "org", @@ -1598,14 +1424,11 @@ func Test_DeleteProjectItem(t *testing.T) { }, { name: "success user delete", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/users/{user}/projectsV2/{project}/items/{item_id}", Method: http.MethodDelete}, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNoContent) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + DeleteUsersProjectsV2ItemsByUsernameByProjectByItemID: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + }), + }), requestArgs: map[string]any{ "owner": "octocat", "owner_type": "user", @@ -1616,12 +1439,9 @@ func Test_DeleteProjectItem(t *testing.T) { }, { name: "api error", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items/{item_id}", Method: http.MethodDelete}, - mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + DeleteOrgsProjectsV2ItemsByProjectByItemID: mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), + }), requestArgs: map[string]any{ "owner": "octo-org", "owner_type": "org", @@ -1633,7 +1453,7 @@ func Test_DeleteProjectItem(t *testing.T) { }, { name: "missing owner", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]any{ "owner_type": "org", "project_number": float64(1), @@ -1643,7 +1463,7 @@ func Test_DeleteProjectItem(t *testing.T) { }, { name: "missing owner_type", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]any{ "owner": "octo-org", "project_number": float64(1), @@ -1653,7 +1473,7 @@ func Test_DeleteProjectItem(t *testing.T) { }, { name: "missing project_number", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]any{ "owner": "octo-org", "owner_type": "org", @@ -1663,7 +1483,7 @@ func Test_DeleteProjectItem(t *testing.T) { }, { name: "missing item_id", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]any{ "owner": "octo-org", "owner_type": "org", @@ -1709,3 +1529,635 @@ func Test_DeleteProjectItem(t *testing.T) { }) } } + +// Tests for consolidated project tools + +func Test_ProjectsList(t *testing.T) { + // Verify tool definition once + toolDef := ProjectsList(translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(toolDef.Tool.Name, toolDef.Tool)) + + assert.Equal(t, "projects_list", toolDef.Tool.Name) + assert.NotEmpty(t, toolDef.Tool.Description) + inputSchema := toolDef.Tool.InputSchema.(*jsonschema.Schema) + assert.Contains(t, inputSchema.Properties, "method") + assert.Contains(t, inputSchema.Properties, "owner") + assert.Contains(t, inputSchema.Properties, "owner_type") + 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"}) +} + +func Test_ProjectsList_ListProjects(t *testing.T) { + toolDef := ProjectsList(translations.NullTranslationHelper) + + orgProjects := []map[string]any{{"id": 1, "node_id": "NODE1", "title": "Org Project"}} + userProjects := []map[string]any{{"id": 2, "node_id": "NODE2", "title": "User Project"}} + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + expectError bool + expectedErrMsg string + expectedLength int + }{ + { + name: "success organization", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetOrgsProjectsV2: mockResponse(t, http.StatusOK, orgProjects), + }), + requestArgs: map[string]any{ + "method": "list_projects", + "owner": "octo-org", + "owner_type": "org", + }, + expectError: false, + expectedLength: 1, + }, + { + name: "success user", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetUsersProjectsV2ByUsername: mockResponse(t, http.StatusOK, userProjects), + }), + requestArgs: map[string]any{ + "method": "list_projects", + "owner": "octocat", + "owner_type": "user", + }, + expectError: false, + expectedLength: 1, + }, + { + name: "missing required parameter method", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), + requestArgs: map[string]any{ + "owner": "octo-org", + "owner_type": "org", + }, + expectError: true, + expectedErrMsg: "missing required parameter: method", + }, + { + name: "unknown method", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), + requestArgs: map[string]any{ + "method": "unknown_method", + "owner": "octo-org", + "owner_type": "org", + }, + expectError: true, + expectedErrMsg: "unknown method: unknown_method", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := gh.NewClient(tc.mockedClient) + deps := BaseDeps{ + Client: client, + } + handler := toolDef.Handler(deps) + request := createMCPRequest(tc.requestArgs) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.Equal(t, tc.expectError, result.IsError) + + textContent := getTextResult(t, result) + + if tc.expectError { + if tc.expectedErrMsg != "" { + assert.Contains(t, textContent.Text, tc.expectedErrMsg) + } + return + } + + var response map[string]any + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + projects, ok := response["projects"].([]interface{}) + require.True(t, ok) + assert.Equal(t, tc.expectedLength, len(projects)) + }) + } +} + +func Test_ProjectsList_ListProjectFields(t *testing.T) { + toolDef := ProjectsList(translations.NullTranslationHelper) + + fields := []map[string]any{{"id": 101, "name": "Status", "data_type": "single_select"}} + + t.Run("success organization", func(t *testing.T) { + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetOrgsProjectsV2FieldsByProject: mockResponse(t, http.StatusOK, fields), + }) + + client := gh.NewClient(mockedClient) + deps := BaseDeps{ + Client: client, + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "list_project_fields", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + }) + 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) + fieldsList, ok := response["fields"].([]interface{}) + require.True(t, ok) + assert.Equal(t, 1, len(fieldsList)) + }) + + t.Run("missing project_number", func(t *testing.T) { + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}) + client := gh.NewClient(mockedClient) + deps := BaseDeps{ + Client: client, + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "list_project_fields", + "owner": "octo-org", + "owner_type": "org", + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.True(t, result.IsError) + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, "missing required parameter: project_number") + }) +} + +func Test_ProjectsList_ListProjectItems(t *testing.T) { + toolDef := ProjectsList(translations.NullTranslationHelper) + + items := []map[string]any{{"id": 1001, "archived_at": nil, "content": map[string]any{"title": "Issue 1"}}} + + t.Run("success organization", func(t *testing.T) { + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetOrgsProjectsV2ItemsByProject: mockResponse(t, http.StatusOK, items), + }) + + client := gh.NewClient(mockedClient) + deps := BaseDeps{ + Client: client, + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "list_project_items", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + }) + 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) + itemsList, ok := response["items"].([]interface{}) + require.True(t, ok) + assert.Equal(t, 1, len(itemsList)) + }) +} + +func Test_ProjectsGet(t *testing.T) { + // Verify tool definition once + toolDef := ProjectsGet(translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(toolDef.Tool.Name, toolDef.Tool)) + + assert.Equal(t, "projects_get", toolDef.Tool.Name) + assert.NotEmpty(t, toolDef.Tool.Description) + inputSchema := toolDef.Tool.InputSchema.(*jsonschema.Schema) + assert.Contains(t, inputSchema.Properties, "method") + assert.Contains(t, inputSchema.Properties, "owner") + assert.Contains(t, inputSchema.Properties, "owner_type") + 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"}) +} + +func Test_ProjectsGet_GetProject(t *testing.T) { + toolDef := ProjectsGet(translations.NullTranslationHelper) + + project := map[string]any{"id": 123, "title": "Project Title"} + + t.Run("success organization", func(t *testing.T) { + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetOrgsProjectsV2ByProject: mockResponse(t, http.StatusOK, project), + }) + + client := gh.NewClient(mockedClient) + deps := BaseDeps{ + Client: client, + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "get_project", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + }) + 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"]) + }) + + t.Run("unknown method", func(t *testing.T) { + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}) + client := gh.NewClient(mockedClient) + deps := BaseDeps{ + Client: client, + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "unknown_method", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.True(t, result.IsError) + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, "unknown method: unknown_method") + }) +} + +func Test_ProjectsGet_GetProjectField(t *testing.T) { + toolDef := ProjectsGet(translations.NullTranslationHelper) + + field := map[string]any{"id": 101, "name": "Status", "data_type": "single_select"} + + t.Run("success organization", func(t *testing.T) { + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetOrgsProjectsV2FieldsByProjectByFieldID: mockResponse(t, http.StatusOK, field), + }) + + client := gh.NewClient(mockedClient) + deps := BaseDeps{ + Client: client, + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "get_project_field", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + "field_id": float64(101), + }) + 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"]) + }) + + t.Run("missing field_id", func(t *testing.T) { + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}) + client := gh.NewClient(mockedClient) + deps := BaseDeps{ + Client: client, + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "get_project_field", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.True(t, result.IsError) + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, "missing required parameter: field_id") + }) +} + +func Test_ProjectsGet_GetProjectItem(t *testing.T) { + toolDef := ProjectsGet(translations.NullTranslationHelper) + + item := map[string]any{"id": 1001, "archived_at": nil, "content": map[string]any{"title": "Issue 1"}} + + t.Run("success organization", func(t *testing.T) { + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetOrgsProjectsV2ItemsByProjectByItemID: mockResponse(t, http.StatusOK, item), + }) + + client := gh.NewClient(mockedClient) + deps := BaseDeps{ + Client: client, + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "get_project_item", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + "item_id": float64(1001), + }) + 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"]) + }) + + t.Run("missing item_id", func(t *testing.T) { + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}) + client := gh.NewClient(mockedClient) + deps := BaseDeps{ + Client: client, + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "get_project_item", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.True(t, result.IsError) + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, "missing required parameter: item_id") + }) +} + +func Test_ProjectsWrite(t *testing.T) { + // Verify tool definition once + toolDef := ProjectsWrite(translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(toolDef.Tool.Name, toolDef.Tool)) + + assert.Equal(t, "projects_write", toolDef.Tool.Name) + assert.NotEmpty(t, toolDef.Tool.Description) + inputSchema := toolDef.Tool.InputSchema.(*jsonschema.Schema) + assert.Contains(t, inputSchema.Properties, "method") + assert.Contains(t, inputSchema.Properties, "owner") + assert.Contains(t, inputSchema.Properties, "owner_type") + 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, "updated_field") + assert.ElementsMatch(t, inputSchema.Required, []string{"method", "owner_type", "owner", "project_number"}) + + // Verify DestructiveHint is set + assert.NotNil(t, toolDef.Tool.Annotations) + assert.NotNil(t, toolDef.Tool.Annotations.DestructiveHint) + assert.True(t, *toolDef.Tool.Annotations.DestructiveHint) +} + +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)), + }) + + client := gh.NewClient(mockedClient) + deps := BaseDeps{ + Client: client, + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "add_project_item", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + "item_id": float64(123), + "item_type": "issue", + }) + 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"]) + }) + + t.Run("missing item_type", func(t *testing.T) { + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}) + client := gh.NewClient(mockedClient) + deps := BaseDeps{ + Client: client, + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "add_project_item", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + "item_id": float64(123), + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.True(t, result.IsError) + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, "missing required parameter: item_type") + }) + + t.Run("invalid item_type", func(t *testing.T) { + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}) + client := gh.NewClient(mockedClient) + deps := BaseDeps{ + Client: client, + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "add_project_item", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + "item_id": float64(123), + "item_type": "invalid_type", + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.True(t, result.IsError) + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, "item_type must be either 'issue' or 'pull_request'") + }) + + t.Run("unknown method", func(t *testing.T) { + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}) + client := gh.NewClient(mockedClient) + deps := BaseDeps{ + Client: client, + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "unknown_method", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.True(t, result.IsError) + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, "unknown method: unknown_method") + }) +} + +func Test_ProjectsWrite_UpdateProjectItem(t *testing.T) { + toolDef := ProjectsWrite(translations.NullTranslationHelper) + + updatedItem := map[string]any{"id": 1001, "archived_at": nil} + + t.Run("success organization", func(t *testing.T) { + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchOrgsProjectsV2ItemsByProjectByItemID: mockResponse(t, http.StatusOK, updatedItem), + }) + + client := gh.NewClient(mockedClient) + deps := BaseDeps{ + Client: client, + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "update_project_item", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + "item_id": float64(1001), + "updated_field": map[string]any{ + "id": float64(101), + "value": "In Progress", + }, + }) + 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"]) + }) + + t.Run("missing updated_field", func(t *testing.T) { + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}) + client := gh.NewClient(mockedClient) + deps := BaseDeps{ + Client: client, + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "update_project_item", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + "item_id": float64(1001), + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.True(t, result.IsError) + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, "missing required parameter: updated_field") + }) +} + +func Test_ProjectsWrite_DeleteProjectItem(t *testing.T) { + toolDef := ProjectsWrite(translations.NullTranslationHelper) + + t.Run("success organization", func(t *testing.T) { + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + DeleteOrgsProjectsV2ItemsByProjectByItemID: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + }), + }) + + client := gh.NewClient(mockedClient) + deps := BaseDeps{ + Client: client, + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "delete_project_item", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + "item_id": float64(1001), + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.False(t, result.IsError) + + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, "project item successfully deleted") + }) + + t.Run("missing item_id", func(t *testing.T) { + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}) + client := gh.NewClient(mockedClient) + deps := BaseDeps{ + Client: client, + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "delete_project_item", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.True(t, result.IsError) + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, "missing required parameter: item_id") + }) +} diff --git a/pkg/github/pullrequests.go b/pkg/github/pullrequests.go index d51c14fa4..62952783e 100644 --- a/pkg/github/pullrequests.go +++ b/pkg/github/pullrequests.go @@ -18,6 +18,7 @@ import ( "github.com/github/github-mcp-server/pkg/lockdown" "github.com/github/github-mcp-server/pkg/octicons" "github.com/github/github-mcp-server/pkg/sanitize" + "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" ) @@ -69,6 +70,7 @@ Possible options: }, InputSchema: schema, }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { method, err := RequiredParam[string](args, "method") if err != nil { @@ -518,6 +520,7 @@ func CreatePullRequest(t translations.TranslationHelperFunc) inventory.ServerToo }, InputSchema: schema, }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { @@ -669,6 +672,7 @@ func UpdatePullRequest(t translations.TranslationHelperFunc) inventory.ServerToo }, InputSchema: schema, }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { @@ -950,6 +954,7 @@ func ListPullRequests(t translations.TranslationHelperFunc) inventory.ServerTool }, InputSchema: schema, }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { @@ -1086,6 +1091,7 @@ func MergePullRequest(t translations.TranslationHelperFunc) inventory.ServerTool }, InputSchema: schema, }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { @@ -1203,6 +1209,7 @@ func SearchPullRequests(t translations.TranslationHelperFunc) inventory.ServerTo }, InputSchema: schema, }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { result, err := searchHandler(ctx, deps.GetClient, args, "pr", "failed to search pull requests") return result, nil, err @@ -1245,6 +1252,7 @@ func UpdatePullRequestBranch(t translations.TranslationHelperFunc) inventory.Ser }, InputSchema: schema, }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { @@ -1371,6 +1379,7 @@ Available methods: }, InputSchema: schema, }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { var params PullRequestReviewWriteParams if err := mapstructure.Decode(args, ¶ms); err != nil { @@ -1694,6 +1703,7 @@ func AddCommentToPendingReview(t translations.TranslationHelperFunc) inventory.S }, InputSchema: schema, }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { var params struct { Owner string @@ -1846,6 +1856,7 @@ func RequestCopilotReview(t translations.TranslationHelperFunc) inventory.Server }, InputSchema: schema, }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { diff --git a/pkg/github/pullrequests_test.go b/pkg/github/pullrequests_test.go index 3cb41515d..d2664479d 100644 --- a/pkg/github/pullrequests_test.go +++ b/pkg/github/pullrequests_test.go @@ -14,8 +14,6 @@ import ( "github.com/google/go-github/v79/github" "github.com/google/jsonschema-go/jsonschema" "github.com/shurcooL/githubv4" - - "github.com/migueleliasweb/go-github-mock/src/mock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -64,12 +62,9 @@ func Test_GetPullRequest(t *testing.T) { }{ { name: "successful PR fetch", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetReposPullsByOwnerByRepoByPullNumber, - mockPR, - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposPullsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, mockPR), + }), requestArgs: map[string]interface{}{ "method": "get", "owner": "owner", @@ -81,15 +76,12 @@ func Test_GetPullRequest(t *testing.T) { }, { name: "PR fetch fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposPullsByOwnerByRepoByPullNumber, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"message": "Not Found"}`)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposPullsByOwnerByRepoByPullNumber: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + }, + }), requestArgs: map[string]interface{}{ "method": "get", "owner": "owner", @@ -209,24 +201,17 @@ func Test_UpdatePullRequest(t *testing.T) { }{ { name: "successful PR update (title, body, base, maintainer_can_modify)", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PatchReposPullsByOwnerByRepoByPullNumber, - // Expect the flat string based on previous test failure output and API docs - expectRequestBody(t, map[string]interface{}{ - "title": "Updated Test PR Title", - "body": "Updated test PR body.", - "base": "develop", - "maintainer_can_modify": false, - }).andThen( - mockResponse(t, http.StatusOK, mockUpdatedPR), - ), - ), - mock.WithRequestMatch( - mock.GetReposPullsByOwnerByRepoByPullNumber, - mockUpdatedPR, - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposPullsByOwnerByRepoByPullNumber: expectRequestBody(t, map[string]interface{}{ + "title": "Updated Test PR Title", + "body": "Updated test PR body.", + "base": "develop", + "maintainer_can_modify": false, + }).andThen( + mockResponse(t, http.StatusOK, mockUpdatedPR), + ), + GetReposPullsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, mockUpdatedPR), + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -241,20 +226,14 @@ func Test_UpdatePullRequest(t *testing.T) { }, { name: "successful PR update (state)", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PatchReposPullsByOwnerByRepoByPullNumber, - expectRequestBody(t, map[string]interface{}{ - "state": "closed", - }).andThen( - mockResponse(t, http.StatusOK, mockClosedPR), - ), - ), - mock.WithRequestMatch( - mock.GetReposPullsByOwnerByRepoByPullNumber, - mockClosedPR, - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposPullsByOwnerByRepoByPullNumber: expectRequestBody(t, map[string]interface{}{ + "state": "closed", + }).andThen( + mockResponse(t, http.StatusOK, mockClosedPR), + ), + GetReposPullsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, mockClosedPR), + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -266,17 +245,10 @@ func Test_UpdatePullRequest(t *testing.T) { }, { name: "successful PR update with reviewers", - mockedClient: mock.NewMockedHTTPClient( - // Mock for RequestReviewers call, returning the PR with reviewers - mock.WithRequestMatch( - mock.PostReposPullsRequestedReviewersByOwnerByRepoByPullNumber, - mockPRWithReviewers, - ), - mock.WithRequestMatch( - mock.GetReposPullsByOwnerByRepoByPullNumber, - mockPRWithReviewers, - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposPullsRequestedReviewersByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, mockPRWithReviewers), + GetReposPullsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, mockPRWithReviewers), + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -288,20 +260,14 @@ func Test_UpdatePullRequest(t *testing.T) { }, { name: "successful PR update (title only)", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PatchReposPullsByOwnerByRepoByPullNumber, - expectRequestBody(t, map[string]interface{}{ - "title": "Updated Test PR Title", - }).andThen( - mockResponse(t, http.StatusOK, mockUpdatedPR), - ), - ), - mock.WithRequestMatch( - mock.GetReposPullsByOwnerByRepoByPullNumber, - mockUpdatedPR, - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposPullsByOwnerByRepoByPullNumber: expectRequestBody(t, map[string]interface{}{ + "title": "Updated Test PR Title", + }).andThen( + mockResponse(t, http.StatusOK, mockUpdatedPR), + ), + GetReposPullsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, mockUpdatedPR), + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -313,7 +279,7 @@ func Test_UpdatePullRequest(t *testing.T) { }, { name: "no update parameters provided", - mockedClient: mock.NewMockedHTTPClient(), // No API call expected + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), // No API call expected requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -325,15 +291,12 @@ func Test_UpdatePullRequest(t *testing.T) { }, { name: "PR update fails (API error)", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PatchReposPullsByOwnerByRepoByPullNumber, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusUnprocessableEntity) - _, _ = w.Write([]byte(`{"message": "Validation Failed"}`)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposPullsByOwnerByRepoByPullNumber: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnprocessableEntity) + _, _ = w.Write([]byte(`{"message": "Validation Failed"}`)) + }, + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -345,16 +308,12 @@ func Test_UpdatePullRequest(t *testing.T) { }, { name: "request reviewers fails", - mockedClient: mock.NewMockedHTTPClient( - // Then reviewer request fails - mock.WithRequestMatchHandler( - mock.PostReposPullsRequestedReviewersByOwnerByRepoByPullNumber, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusUnprocessableEntity) - _, _ = w.Write([]byte(`{"message": "Invalid reviewers"}`)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposPullsRequestedReviewersByOwnerByRepoByPullNumber: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnprocessableEntity) + _, _ = w.Write([]byte(`{"message": "Invalid reviewers"}`)) + }, + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -553,12 +512,9 @@ func Test_UpdatePullRequest_Draft(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // For draft-only tests, we need to mock both GraphQL and the final REST GET call - restClient := github.NewClient(mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetReposPullsByOwnerByRepoByPullNumber, - mockUpdatedPR, - ), - )) + restClient := github.NewClient(MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposPullsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, mockUpdatedPR), + })) gqlClient := githubv4.NewClient(tc.mockedClient) serverTool := UpdatePullRequest(translations.NullTranslationHelper) @@ -642,20 +598,17 @@ func Test_ListPullRequests(t *testing.T) { }{ { name: "successful PRs listing", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposPullsByOwnerByRepo, - expectQueryParams(t, map[string]string{ - "state": "all", - "sort": "created", - "direction": "desc", - "per_page": "30", - "page": "1", - }).andThen( - mockResponse(t, http.StatusOK, mockPRs), - ), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposPullsByOwnerByRepo: expectQueryParams(t, map[string]string{ + "state": "all", + "sort": "created", + "direction": "desc", + "per_page": "30", + "page": "1", + }).andThen( + mockResponse(t, http.StatusOK, mockPRs), + ), + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -670,15 +623,12 @@ func Test_ListPullRequests(t *testing.T) { }, { name: "PRs listing fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposPullsByOwnerByRepo, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusBadRequest) - _, _ = w.Write([]byte(`{"message": "Invalid request"}`)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposPullsByOwnerByRepo: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"message": "Invalid request"}`)) + }, + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -769,18 +719,15 @@ func Test_MergePullRequest(t *testing.T) { }{ { name: "successful merge", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PutReposPullsMergeByOwnerByRepoByPullNumber, - expectRequestBody(t, map[string]interface{}{ - "commit_title": "Merge PR #42", - "commit_message": "Merging awesome feature", - "merge_method": "squash", - }).andThen( - mockResponse(t, http.StatusOK, mockMergeResult), - ), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PutReposPullsMergeByOwnerByRepoByPullNumber: expectRequestBody(t, map[string]interface{}{ + "commit_title": "Merge PR #42", + "commit_message": "Merging awesome feature", + "merge_method": "squash", + }).andThen( + mockResponse(t, http.StatusOK, mockMergeResult), + ), + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -794,15 +741,12 @@ func Test_MergePullRequest(t *testing.T) { }, { name: "merge fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PutReposPullsMergeByOwnerByRepoByPullNumber, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusMethodNotAllowed) - _, _ = w.Write([]byte(`{"message": "Pull request cannot be merged"}`)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PutReposPullsMergeByOwnerByRepoByPullNumber: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusMethodNotAllowed) + _, _ = w.Write([]byte(`{"message": "Pull request cannot be merged"}`)) + }, + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -911,23 +855,20 @@ func Test_SearchPullRequests(t *testing.T) { }{ { name: "successful pull request search with all parameters", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchIssues, - expectQueryParams( - t, - map[string]string{ - "q": "is:pr repo:owner/repo is:open", - "sort": "created", - "order": "desc", - "page": "1", - "per_page": "30", - }, - ).andThen( - mockResponse(t, http.StatusOK, mockSearchResult), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchIssues: expectQueryParams( + t, + map[string]string{ + "q": "is:pr repo:owner/repo is:open", + "sort": "created", + "order": "desc", + "page": "1", + "per_page": "30", + }, + ).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), ), - ), + }), requestArgs: map[string]interface{}{ "query": "repo:owner/repo is:open", "sort": "created", @@ -940,23 +881,20 @@ func Test_SearchPullRequests(t *testing.T) { }, { name: "pull request search with owner and repo parameters", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchIssues, - expectQueryParams( - t, - map[string]string{ - "q": "repo:test-owner/test-repo is:pr draft:false", - "sort": "updated", - "order": "asc", - "page": "1", - "per_page": "30", - }, - ).andThen( - mockResponse(t, http.StatusOK, mockSearchResult), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchIssues: expectQueryParams( + t, + map[string]string{ + "q": "repo:test-owner/test-repo is:pr draft:false", + "sort": "updated", + "order": "asc", + "page": "1", + "per_page": "30", + }, + ).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), ), - ), + }), requestArgs: map[string]interface{}{ "query": "draft:false", "owner": "test-owner", @@ -969,21 +907,18 @@ func Test_SearchPullRequests(t *testing.T) { }, { name: "pull request search with only owner parameter (should ignore it)", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchIssues, - expectQueryParams( - t, - map[string]string{ - "q": "is:pr feature", - "page": "1", - "per_page": "30", - }, - ).andThen( - mockResponse(t, http.StatusOK, mockSearchResult), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchIssues: expectQueryParams( + t, + map[string]string{ + "q": "is:pr feature", + "page": "1", + "per_page": "30", + }, + ).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), ), - ), + }), requestArgs: map[string]interface{}{ "query": "feature", "owner": "test-owner", @@ -993,21 +928,18 @@ func Test_SearchPullRequests(t *testing.T) { }, { name: "pull request search with only repo parameter (should ignore it)", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchIssues, - expectQueryParams( - t, - map[string]string{ - "q": "is:pr review-required", - "page": "1", - "per_page": "30", - }, - ).andThen( - mockResponse(t, http.StatusOK, mockSearchResult), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchIssues: expectQueryParams( + t, + map[string]string{ + "q": "is:pr review-required", + "page": "1", + "per_page": "30", + }, + ).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), ), - ), + }), requestArgs: map[string]interface{}{ "query": "review-required", "repo": "test-repo", @@ -1017,12 +949,9 @@ func Test_SearchPullRequests(t *testing.T) { }, { name: "pull request search with minimal parameters", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetSearchIssues, - mockSearchResult, - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchIssues: mockResponse(t, http.StatusOK, mockSearchResult), + }), requestArgs: map[string]interface{}{ "query": "is:pr repo:owner/repo is:open", }, @@ -1031,21 +960,18 @@ func Test_SearchPullRequests(t *testing.T) { }, { name: "query with existing is:pr filter - no duplication", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchIssues, - expectQueryParams( - t, - map[string]string{ - "q": "is:pr repo:github/github-mcp-server is:open draft:false", - "page": "1", - "per_page": "30", - }, - ).andThen( - mockResponse(t, http.StatusOK, mockSearchResult), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchIssues: expectQueryParams( + t, + map[string]string{ + "q": "is:pr repo:github/github-mcp-server is:open draft:false", + "page": "1", + "per_page": "30", + }, + ).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), ), - ), + }), requestArgs: map[string]interface{}{ "query": "is:pr repo:github/github-mcp-server is:open draft:false", }, @@ -1054,21 +980,18 @@ func Test_SearchPullRequests(t *testing.T) { }, { name: "query with existing repo: filter and conflicting owner/repo params - uses query filter", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchIssues, - expectQueryParams( - t, - map[string]string{ - "q": "is:pr repo:github/github-mcp-server author:octocat", - "page": "1", - "per_page": "30", - }, - ).andThen( - mockResponse(t, http.StatusOK, mockSearchResult), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchIssues: expectQueryParams( + t, + map[string]string{ + "q": "is:pr repo:github/github-mcp-server author:octocat", + "page": "1", + "per_page": "30", + }, + ).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), ), - ), + }), requestArgs: map[string]interface{}{ "query": "repo:github/github-mcp-server author:octocat", "owner": "different-owner", @@ -1079,21 +1002,18 @@ func Test_SearchPullRequests(t *testing.T) { }, { name: "complex query with existing is:pr filter and OR operators", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchIssues, - expectQueryParams( - t, - map[string]string{ - "q": "is:pr repo:github/github-mcp-server (label:bug OR label:enhancement OR label:feature)", - "page": "1", - "per_page": "30", - }, - ).andThen( - mockResponse(t, http.StatusOK, mockSearchResult), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchIssues: expectQueryParams( + t, + map[string]string{ + "q": "is:pr repo:github/github-mcp-server (label:bug OR label:enhancement OR label:feature)", + "page": "1", + "per_page": "30", + }, + ).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), ), - ), + }), requestArgs: map[string]interface{}{ "query": "is:pr repo:github/github-mcp-server (label:bug OR label:enhancement OR label:feature)", }, @@ -1102,15 +1022,12 @@ func Test_SearchPullRequests(t *testing.T) { }, { name: "search pull requests fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchIssues, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusBadRequest) - _, _ = w.Write([]byte(`{"message": "Validation Failed"}`)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchIssues: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"message": "Validation Failed"}`)) + }, + }), requestArgs: map[string]interface{}{ "query": "invalid:query", }, @@ -1216,12 +1133,14 @@ func Test_GetPullRequestFiles(t *testing.T) { }{ { name: "successful files fetch", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetReposPullsFilesByOwnerByRepoByPullNumber, - mockFiles, - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposPullsFilesByOwnerByRepoByPullNumber: expectQueryParams(t, map[string]string{ + "page": "1", + "per_page": "30", + }).andThen( + mockResponse(t, http.StatusOK, mockFiles), + ), + }), requestArgs: map[string]interface{}{ "method": "get_files", "owner": "owner", @@ -1233,12 +1152,14 @@ func Test_GetPullRequestFiles(t *testing.T) { }, { name: "successful files fetch with pagination", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetReposPullsFilesByOwnerByRepoByPullNumber, - mockFiles, - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposPullsFilesByOwnerByRepoByPullNumber: expectQueryParams(t, map[string]string{ + "page": "2", + "per_page": "10", + }).andThen( + mockResponse(t, http.StatusOK, mockFiles), + ), + }), requestArgs: map[string]interface{}{ "method": "get_files", "owner": "owner", @@ -1252,15 +1173,17 @@ func Test_GetPullRequestFiles(t *testing.T) { }, { name: "files fetch fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposPullsFilesByOwnerByRepoByPullNumber, + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposPullsFilesByOwnerByRepoByPullNumber: expectQueryParams(t, map[string]string{ + "page": "1", + "per_page": "30", + }).andThen( http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNotFound) _, _ = w.Write([]byte(`{"message": "Not Found"}`)) }), ), - ), + }), requestArgs: map[string]interface{}{ "method": "get_files", "owner": "owner", @@ -1382,16 +1305,10 @@ func Test_GetPullRequestStatus(t *testing.T) { }{ { name: "successful status fetch", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetReposPullsByOwnerByRepoByPullNumber, - mockPR, - ), - mock.WithRequestMatch( - mock.GetReposCommitsStatusByOwnerByRepoByRef, - mockStatus, - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposPullsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, mockPR), + GetReposCommitsStatusByOwnerByRepoByRef: mockResponse(t, http.StatusOK, mockStatus), + }), requestArgs: map[string]interface{}{ "method": "get_status", "owner": "owner", @@ -1403,15 +1320,12 @@ func Test_GetPullRequestStatus(t *testing.T) { }, { name: "PR fetch fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposPullsByOwnerByRepoByPullNumber, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"message": "Not Found"}`)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposPullsByOwnerByRepoByPullNumber: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + }), + }), requestArgs: map[string]interface{}{ "method": "get_status", "owner": "owner", @@ -1423,19 +1337,13 @@ func Test_GetPullRequestStatus(t *testing.T) { }, { name: "status fetch fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetReposPullsByOwnerByRepoByPullNumber, - mockPR, - ), - mock.WithRequestMatchHandler( - mock.GetReposCommitsStatusesByOwnerByRepoByRef, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"message": "Not Found"}`)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposPullsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, mockPR), + GetReposCommitsStatusesByOwnerByRepoByRef: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + }), + }), requestArgs: map[string]interface{}{ "method": "get_status", "owner": "owner", @@ -1527,16 +1435,13 @@ func Test_UpdatePullRequestBranch(t *testing.T) { }{ { name: "successful branch update", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PutReposPullsUpdateBranchByOwnerByRepoByPullNumber, - expectRequestBody(t, map[string]interface{}{ - "expected_head_sha": "abcd1234", - }).andThen( - mockResponse(t, http.StatusAccepted, mockUpdateResult), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PutReposPullsUpdateBranchByOwnerByRepoByPullNumber: expectRequestBody(t, map[string]interface{}{ + "expected_head_sha": "abcd1234", + }).andThen( + mockResponse(t, http.StatusAccepted, mockUpdateResult), ), - ), + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -1548,14 +1453,11 @@ func Test_UpdatePullRequestBranch(t *testing.T) { }, { name: "branch update without expected SHA", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PutReposPullsUpdateBranchByOwnerByRepoByPullNumber, - expectRequestBody(t, map[string]interface{}{}).andThen( - mockResponse(t, http.StatusAccepted, mockUpdateResult), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PutReposPullsUpdateBranchByOwnerByRepoByPullNumber: expectRequestBody(t, map[string]interface{}{}).andThen( + mockResponse(t, http.StatusAccepted, mockUpdateResult), ), - ), + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -1566,15 +1468,12 @@ func Test_UpdatePullRequestBranch(t *testing.T) { }, { name: "branch update fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PutReposPullsUpdateBranchByOwnerByRepoByPullNumber, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusConflict) - _, _ = w.Write([]byte(`{"message": "Merge conflict"}`)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PutReposPullsUpdateBranchByOwnerByRepoByPullNumber: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusConflict) + _, _ = w.Write([]byte(`{"message": "Merge conflict"}`)) + }), + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -1997,12 +1896,9 @@ func Test_GetPullRequestReviews(t *testing.T) { }{ { name: "successful reviews fetch", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetReposPullsReviewsByOwnerByRepoByPullNumber, - mockReviews, - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposPullsReviewsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, mockReviews), + }), requestArgs: map[string]interface{}{ "method": "get_reviews", "owner": "owner", @@ -2014,15 +1910,12 @@ func Test_GetPullRequestReviews(t *testing.T) { }, { name: "reviews fetch fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposPullsReviewsByOwnerByRepoByPullNumber, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"message": "Not Found"}`)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposPullsReviewsByOwnerByRepoByPullNumber: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + }), + }), requestArgs: map[string]interface{}{ "method": "get_reviews", "owner": "owner", @@ -2034,25 +1927,22 @@ func Test_GetPullRequestReviews(t *testing.T) { }, { name: "lockdown enabled filters reviews without push access", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetReposPullsReviewsByOwnerByRepoByPullNumber, - []*github.PullRequestReview{ - { - ID: github.Ptr(int64(2030)), - State: github.Ptr("APPROVED"), - Body: github.Ptr("Maintainer review"), - User: &github.User{Login: github.Ptr("maintainer")}, - }, - { - ID: github.Ptr(int64(2031)), - State: github.Ptr("COMMENTED"), - Body: github.Ptr("External reviewer"), - User: &github.User{Login: github.Ptr("testuser")}, - }, + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposPullsReviewsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, []*github.PullRequestReview{ + { + ID: github.Ptr(int64(2030)), + State: github.Ptr("APPROVED"), + Body: github.Ptr("Maintainer review"), + User: &github.User{Login: github.Ptr("maintainer")}, }, - ), - ), + { + ID: github.Ptr(int64(2031)), + State: github.Ptr("COMMENTED"), + Body: github.Ptr("External reviewer"), + User: &github.User{Login: github.Ptr("testuser")}, + }, + }), + }), gqlHTTPClient: newRepoAccessHTTPClient(), requestArgs: map[string]interface{}{ "method": "get_reviews", @@ -2183,21 +2073,18 @@ func Test_CreatePullRequest(t *testing.T) { }{ { name: "successful PR creation", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PostReposPullsByOwnerByRepo, - expectRequestBody(t, map[string]interface{}{ - "title": "Test PR", - "body": "This is a test PR", - "head": "feature-branch", - "base": "main", - "draft": false, - "maintainer_can_modify": true, - }).andThen( - mockResponse(t, http.StatusCreated, mockPR), - ), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposPullsByOwnerByRepo: expectRequestBody(t, map[string]interface{}{ + "title": "Test PR", + "body": "This is a test PR", + "head": "feature-branch", + "base": "main", + "draft": false, + "maintainer_can_modify": true, + }).andThen( + mockResponse(t, http.StatusCreated, mockPR), + ), + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -2213,7 +2100,7 @@ func Test_CreatePullRequest(t *testing.T) { }, { name: "missing required parameter", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -2224,15 +2111,12 @@ func Test_CreatePullRequest(t *testing.T) { }, { name: "PR creation fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PostReposPullsByOwnerByRepo, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusUnprocessableEntity) - _, _ = w.Write([]byte(`{"message":"Validation failed","errors":[{"resource":"PullRequest","code":"invalid"}]}`)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposPullsByOwnerByRepo: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnprocessableEntity) + _, _ = w.Write([]byte(`{"message":"Validation failed","errors":[{"resource":"PullRequest","code":"invalid"}]}`)) + }), + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -2535,19 +2419,16 @@ func Test_RequestCopilotReview(t *testing.T) { }{ { name: "successful request", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PostReposPullsRequestedReviewersByOwnerByRepoByPullNumber, - expect(t, expectations{ - path: "/repos/owner/repo/pulls/1/requested_reviewers", - requestBody: map[string]any{ - "reviewers": []any{"copilot-pull-request-reviewer[bot]"}, - }, - }).andThen( - mockResponse(t, http.StatusCreated, mockPR), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposPullsRequestedReviewersByOwnerByRepoByPullNumber: expect(t, expectations{ + path: "/repos/owner/repo/pulls/1/requested_reviewers", + requestBody: map[string]any{ + "reviewers": []any{"copilot-pull-request-reviewer[bot]"}, + }, + }).andThen( + mockResponse(t, http.StatusCreated, mockPR), ), - ), + }), requestArgs: map[string]any{ "owner": "owner", "repo": "repo", @@ -2557,15 +2438,12 @@ func Test_RequestCopilotReview(t *testing.T) { }, { name: "request fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PostReposPullsRequestedReviewersByOwnerByRepoByPullNumber, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"message": "Not Found"}`)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposPullsRequestedReviewersByOwnerByRepoByPullNumber: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + }), + }), requestArgs: map[string]any{ "owner": "owner", "repo": "repo", @@ -3234,15 +3112,11 @@ index 5d6e7b2..8a4f5c3 100644 "repo": "repo", "pullNumber": float64(42), }, - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposPullsByOwnerByRepoByPullNumber, - // Should also expect Accept header to be application/vnd.github.v3.diff - expectPath(t, "/repos/owner/repo/pulls/42").andThen( - mockResponse(t, http.StatusOK, stubbedDiff), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposPullsByOwnerByRepoByPullNumber: expectPath(t, "/repos/owner/repo/pulls/42").andThen( + mockResponse(t, http.StatusOK, stubbedDiff), ), - ), + }), expectToolError: false, }, } diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go index c31bb7df2..f6203f39f 100644 --- a/pkg/github/repositories.go +++ b/pkg/github/repositories.go @@ -12,7 +12,7 @@ import ( ghErrors "github.com/github/github-mcp-server/pkg/errors" "github.com/github/github-mcp-server/pkg/inventory" "github.com/github/github-mcp-server/pkg/octicons" - "github.com/github/github-mcp-server/pkg/raw" + "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" "github.com/google/go-github/v79/github" @@ -54,6 +54,7 @@ func GetCommit(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"owner", "repo", "sha"}, }), }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { @@ -150,6 +151,7 @@ func ListCommits(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"owner", "repo"}, }), }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { @@ -249,6 +251,7 @@ func ListBranches(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"owner", "repo"}, }), }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { @@ -362,6 +365,7 @@ If the SHA is not provided, the tool will attempt to acquire it by fetching the Required: []string{"owner", "repo", "path", "content", "message", "branch"}, }, }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { @@ -545,6 +549,7 @@ func CreateRepository(t translations.TranslationHelperFunc) inventory.ServerTool Required: []string{"name"}, }, }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { name, err := RequiredParam[string](args, "name") if err != nil { @@ -651,6 +656,7 @@ func GetFileContents(t translations.TranslationHelperFunc) inventory.ServerTool Required: []string{"owner", "repo"}, }, }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { @@ -834,6 +840,7 @@ func ForkRepository(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"owner", "repo"}, }, }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { @@ -940,6 +947,7 @@ func DeleteFile(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"owner", "repo", "path", "message", "branch"}, }, }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { @@ -1119,6 +1127,7 @@ func CreateBranch(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"owner", "repo", "branch"}, }, }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { @@ -1249,6 +1258,7 @@ func PushFiles(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"owner", "repo", "branch", "files", "message"}, }, }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { @@ -1279,28 +1289,74 @@ func PushFiles(t translations.TranslationHelperFunc) inventory.ServerTool { } // Get the reference for the branch + var repositoryIsEmpty bool + var branchNotFound bool ref, resp, err := client.Git.GetRef(ctx, owner, repo, "refs/heads/"+branch) if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - "failed to get branch reference", - resp, - err, - ), nil, nil + ghErr, isGhErr := err.(*github.ErrorResponse) + if isGhErr { + if ghErr.Response.StatusCode == http.StatusConflict && ghErr.Message == "Git Repository is empty." { + repositoryIsEmpty = true + } else if ghErr.Response.StatusCode == http.StatusNotFound { + branchNotFound = true + } + } + + if !repositoryIsEmpty && !branchNotFound { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to get branch reference", + resp, + err, + ), nil, nil + } + } + // Only close resp if it's not nil and not an error case where resp might be nil + if resp != nil && resp.Body != nil { + defer func() { _ = resp.Body.Close() }() } - defer func() { _ = resp.Body.Close() }() - // Get the commit object that the branch points to - baseCommit, resp, err := client.Git.GetCommit(ctx, owner, repo, *ref.Object.SHA) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - "failed to get base commit", - resp, - err, - ), nil, nil + var baseCommit *github.Commit + if !repositoryIsEmpty { + if branchNotFound { + ref, err = createReferenceFromDefaultBranch(ctx, client, owner, repo, branch) + if err != nil { + return utils.NewToolResultError(fmt.Sprintf("failed to create branch from default: %v", err)), nil, nil + } + } + + // Get the commit object that the branch points to + baseCommit, resp, err = client.Git.GetCommit(ctx, owner, repo, *ref.Object.SHA) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to get base commit", + resp, + err, + ), nil, nil + } + if resp != nil && resp.Body != nil { + defer func() { _ = resp.Body.Close() }() + } + } else { + var base *github.Commit + // Repository is empty, need to initialize it first + ref, base, err = initializeRepository(ctx, client, owner, repo) + if err != nil { + return utils.NewToolResultError(fmt.Sprintf("failed to initialize repository: %v", err)), nil, nil + } + + defaultBranch := strings.TrimPrefix(*ref.Ref, "refs/heads/") + if branch != defaultBranch { + // Create the requested branch from the default branch + ref, err = createReferenceFromDefaultBranch(ctx, client, owner, repo, branch) + if err != nil { + return utils.NewToolResultError(fmt.Sprintf("failed to create branch from default: %v", err)), nil, nil + } + } + + baseCommit = base } - defer func() { _ = resp.Body.Close() }() - // Create tree entries for all files + // Create tree entries for all files (or remaining files if empty repo) var entries []*github.TreeEntry for _, file := range filesObj { @@ -1328,7 +1384,7 @@ func PushFiles(t translations.TranslationHelperFunc) inventory.ServerTool { }) } - // Create a new tree with the file entries + // Create a new tree with the file entries (baseCommit is now guaranteed to exist) newTree, resp, err := client.Git.CreateTree(ctx, owner, repo, *baseCommit.Tree.SHA, entries) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, @@ -1337,9 +1393,11 @@ func PushFiles(t translations.TranslationHelperFunc) inventory.ServerTool { err, ), nil, nil } - defer func() { _ = resp.Body.Close() }() + if resp != nil && resp.Body != nil { + defer func() { _ = resp.Body.Close() }() + } - // Create a new commit + // Create a new commit (baseCommit always has a value now) commit := github.Commit{ Message: github.Ptr(message), Tree: newTree, @@ -1353,7 +1411,9 @@ func PushFiles(t translations.TranslationHelperFunc) inventory.ServerTool { err, ), nil, nil } - defer func() { _ = resp.Body.Close() }() + if resp != nil && resp.Body != nil { + defer func() { _ = resp.Body.Close() }() + } // Update the reference to point to the new commit ref.Object.SHA = newCommit.SHA @@ -1406,6 +1466,7 @@ func ListTags(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"owner", "repo"}, }), }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { @@ -1488,6 +1549,7 @@ func GetTag(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"owner", "repo", "tag"}, }, }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { @@ -1581,6 +1643,7 @@ func ListReleases(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"owner", "repo"}, }), }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { @@ -1655,6 +1718,7 @@ func GetLatestRelease(t translations.TranslationHelperFunc) inventory.ServerTool Required: []string{"owner", "repo"}, }, }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { @@ -1723,6 +1787,7 @@ func GetReleaseByTag(t translations.TranslationHelperFunc) inventory.ServerTool Required: []string{"owner", "repo", "tag"}, }, }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { @@ -1770,244 +1835,6 @@ func GetReleaseByTag(t translations.TranslationHelperFunc) inventory.ServerTool ) } -// matchFiles searches for files in the Git tree that match the given path. -// It's used when GetContents fails or returns unexpected results. -func matchFiles(ctx context.Context, client *github.Client, owner, repo, ref, path string, rawOpts *raw.ContentOpts, rawAPIResponseCode int) (*mcp.CallToolResult, any, error) { - // Step 1: Get Git Tree recursively - tree, response, err := client.Git.GetTree(ctx, owner, repo, ref, true) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - "failed to get git tree", - response, - err, - ), nil, nil - } - defer func() { _ = response.Body.Close() }() - - // Step 2: Filter tree for matching paths - const maxMatchingFiles = 3 - matchingFiles := filterPaths(tree.Entries, path, maxMatchingFiles) - if len(matchingFiles) > 0 { - matchingFilesJSON, err := json.Marshal(matchingFiles) - if err != nil { - return utils.NewToolResultError(fmt.Sprintf("failed to marshal matching files: %s", err)), nil, nil - } - resolvedRefs, err := json.Marshal(rawOpts) - if err != nil { - return utils.NewToolResultError(fmt.Sprintf("failed to marshal resolved refs: %s", err)), nil, nil - } - if rawAPIResponseCode > 0 { - return utils.NewToolResultText(fmt.Sprintf("Resolved potential matches in the repository tree (resolved refs: %s, matching files: %s), but the content API returned an unexpected status code %d.", string(resolvedRefs), string(matchingFilesJSON), rawAPIResponseCode)), nil, nil - } - return utils.NewToolResultText(fmt.Sprintf("Resolved potential matches in the repository tree (resolved refs: %s, matching files: %s).", string(resolvedRefs), string(matchingFilesJSON))), nil, nil - } - return utils.NewToolResultError("Failed to get file contents. The path does not point to a file or directory, or the file does not exist in the repository."), nil, nil -} - -// filterPaths filters the entries in a GitHub tree to find paths that -// match the given suffix. -// maxResults limits the number of results returned to first maxResults entries, -// a maxResults of -1 means no limit. -// It returns a slice of strings containing the matching paths. -// Directories are returned with a trailing slash. -func filterPaths(entries []*github.TreeEntry, path string, maxResults int) []string { - // Remove trailing slash for matching purposes, but flag whether we - // only want directories. - dirOnly := false - if strings.HasSuffix(path, "/") { - dirOnly = true - path = strings.TrimSuffix(path, "/") - } - - matchedPaths := []string{} - for _, entry := range entries { - if len(matchedPaths) == maxResults { - break // Limit the number of results to maxResults - } - if dirOnly && entry.GetType() != "tree" { - continue // Skip non-directory entries if dirOnly is true - } - entryPath := entry.GetPath() - if entryPath == "" { - continue // Skip empty paths - } - if strings.HasSuffix(entryPath, path) { - if entry.GetType() == "tree" { - entryPath += "/" // Return directories with a trailing slash - } - matchedPaths = append(matchedPaths, entryPath) - } - } - return matchedPaths -} - -// looksLikeSHA returns true if the string appears to be a Git commit SHA. -// A SHA is a 40-character hexadecimal string. -func looksLikeSHA(s string) bool { - if len(s) != 40 { - return false - } - for _, c := range s { - if (c < '0' || c > '9') && (c < 'a' || c > 'f') && (c < 'A' || c > 'F') { - return false - } - } - return true -} - -// resolveGitReference takes a user-provided ref and sha and resolves them into a -// definitive commit SHA and its corresponding fully-qualified reference. -// -// The resolution logic follows a clear priority: -// -// 1. If a specific commit `sha` is provided, it takes precedence and is used directly, -// and all reference resolution is skipped. -// -// 1a. If `sha` is empty but `ref` looks like a commit SHA (40 hexadecimal characters), -// it is returned as-is without any API calls or reference resolution. -// -// 2. If no `sha` is provided and `ref` does not look like a SHA, the function resolves -// the `ref` string into a fully-qualified format (e.g., "refs/heads/main") by trying -// the following steps in order: -// a). **Empty Ref:** If `ref` is empty, the repository's default branch is used. -// b). **Fully-Qualified:** If `ref` already starts with "refs/", it's considered fully -// qualified and used as-is. -// c). **Partially-Qualified:** If `ref` starts with "heads/" or "tags/", it is -// prefixed with "refs/" to make it fully-qualified. -// d). **Short Name:** Otherwise, the `ref` is treated as a short name. The function -// first attempts to resolve it as a branch ("refs/heads/"). If that -// returns a 404 Not Found error, it then attempts to resolve it as a tag -// ("refs/tags/"). -// -// 3. **Final Lookup:** Once a fully-qualified ref is determined, a final API call -// is made to fetch that reference's definitive commit SHA. -// -// Any unexpected (non-404) errors during the resolution process are returned -// immediately. All API errors are logged with rich context to aid diagnostics. -func resolveGitReference(ctx context.Context, githubClient *github.Client, owner, repo, ref, sha string) (*raw.ContentOpts, bool, error) { - // 1) If SHA explicitly provided, it's the highest priority. - if sha != "" { - return &raw.ContentOpts{Ref: "", SHA: sha}, false, nil - } - - // 1a) If sha is empty but ref looks like a SHA, return it without changes - if looksLikeSHA(ref) { - return &raw.ContentOpts{Ref: "", SHA: ref}, false, nil - } - - originalRef := ref // Keep original ref for clearer error messages down the line. - - // 2) If no SHA is provided, we try to resolve the ref into a fully-qualified format. - var reference *github.Reference - var resp *github.Response - var err error - var fallbackUsed bool - - switch { - case originalRef == "": - // 2a) If ref is empty, determine the default branch. - reference, err = resolveDefaultBranch(ctx, githubClient, owner, repo) - if err != nil { - return nil, false, err // Error is already wrapped in resolveDefaultBranch. - } - ref = reference.GetRef() - case strings.HasPrefix(originalRef, "refs/"): - // 2b) Already fully qualified. The reference will be fetched at the end. - case strings.HasPrefix(originalRef, "heads/") || strings.HasPrefix(originalRef, "tags/"): - // 2c) Partially qualified. Make it fully qualified. - ref = "refs/" + originalRef - default: - // 2d) It's a short name, so we try to resolve it to either a branch or a tag. - branchRef := "refs/heads/" + originalRef - reference, resp, err = githubClient.Git.GetRef(ctx, owner, repo, branchRef) - - if err == nil { - ref = branchRef // It's a branch. - } else { - // The branch lookup failed. Check if it was a 404 Not Found error. - ghErr, isGhErr := err.(*github.ErrorResponse) - if isGhErr && ghErr.Response.StatusCode == http.StatusNotFound { - tagRef := "refs/tags/" + originalRef - reference, resp, err = githubClient.Git.GetRef(ctx, owner, repo, tagRef) - if err == nil { - ref = tagRef // It's a tag. - } else { - // The tag lookup also failed. Check if it was a 404 Not Found error. - ghErr2, isGhErr2 := err.(*github.ErrorResponse) - if isGhErr2 && ghErr2.Response.StatusCode == http.StatusNotFound { - if originalRef == "main" { - reference, err = resolveDefaultBranch(ctx, githubClient, owner, repo) - if err != nil { - return nil, false, err // Error is already wrapped in resolveDefaultBranch. - } - // Update ref to the actual default branch ref so the note can be generated - ref = reference.GetRef() - fallbackUsed = true - break - } - return nil, false, fmt.Errorf("could not resolve ref %q as a branch or a tag", originalRef) - } - - // The tag lookup failed for a different reason. - _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get reference (tag)", resp, err) - return nil, false, fmt.Errorf("failed to get reference for tag '%s': %w", originalRef, err) - } - } else { - // The branch lookup failed for a different reason. - _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get reference (branch)", resp, err) - return nil, false, fmt.Errorf("failed to get reference for branch '%s': %w", originalRef, err) - } - } - } - - if reference == nil { - reference, resp, err = githubClient.Git.GetRef(ctx, owner, repo, ref) - if err != nil { - if ref == "refs/heads/main" { - reference, err = resolveDefaultBranch(ctx, githubClient, owner, repo) - if err != nil { - return nil, false, err // Error is already wrapped in resolveDefaultBranch. - } - // Update ref to the actual default branch ref so the note can be generated - ref = reference.GetRef() - fallbackUsed = true - } else { - _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get final reference", resp, err) - return nil, false, fmt.Errorf("failed to get final reference for %q: %w", ref, err) - } - } - } - - sha = reference.GetObject().GetSHA() - return &raw.ContentOpts{Ref: ref, SHA: sha}, fallbackUsed, nil -} - -func resolveDefaultBranch(ctx context.Context, githubClient *github.Client, owner, repo string) (*github.Reference, error) { - repoInfo, resp, err := githubClient.Repositories.Get(ctx, owner, repo) - if err != nil { - _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get repository info", resp, err) - return nil, fmt.Errorf("failed to get repository info: %w", err) - } - - if resp != nil && resp.Body != nil { - _ = resp.Body.Close() - } - - defaultBranch := repoInfo.GetDefaultBranch() - - defaultRef, resp, err := githubClient.Git.GetRef(ctx, owner, repo, "heads/"+defaultBranch) - if err != nil { - _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get default branch reference", resp, err) - return nil, fmt.Errorf("failed to get default branch reference: %w", err) - } - - if resp != nil && resp.Body != nil { - defer func() { _ = resp.Body.Close() }() - } - - return defaultRef, nil -} - // ListStarredRepositories creates a tool to list starred repositories for the authenticated user or a specified user. func ListStarredRepositories(t translations.TranslationHelperFunc) inventory.ServerTool { return NewTool( @@ -2039,6 +1866,7 @@ func ListStarredRepositories(t translations.TranslationHelperFunc) inventory.Ser }, }), }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { username, err := OptionalParam[string](args, "username") if err != nil { @@ -2166,6 +1994,7 @@ func StarRepository(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"owner", "repo"}, }, }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { @@ -2230,6 +2059,7 @@ func UnstarRepository(t translations.TranslationHelperFunc) inventory.ServerTool Required: []string{"owner", "repo"}, }, }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { diff --git a/pkg/github/repositories_helper.go b/pkg/github/repositories_helper.go new file mode 100644 index 000000000..de5065d48 --- /dev/null +++ b/pkg/github/repositories_helper.go @@ -0,0 +1,329 @@ +package github + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + + ghErrors "github.com/github/github-mcp-server/pkg/errors" + "github.com/github/github-mcp-server/pkg/raw" + "github.com/github/github-mcp-server/pkg/utils" + "github.com/google/go-github/v79/github" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// initializeRepository creates an initial commit in an empty repository and returns the default branch ref and base commit +func initializeRepository(ctx context.Context, client *github.Client, owner, repo string) (ref *github.Reference, baseCommit *github.Commit, err error) { + // First, we need to check what the default branch in this empty repo should be: + repository, resp, err := client.Repositories.Get(ctx, owner, repo) + if err != nil { + _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get repository", resp, err) + return nil, nil, fmt.Errorf("failed to get repository: %w", err) + } + if resp != nil && resp.Body != nil { + defer func() { _ = resp.Body.Close() }() + } + + defaultBranch := repository.GetDefaultBranch() + + fileOpts := &github.RepositoryContentFileOptions{ + Message: github.Ptr("Initial commit"), + Content: []byte(""), + Branch: github.Ptr(defaultBranch), + } + + // Create an initial empty commit to create the default branch + createResp, resp, err := client.Repositories.CreateFile(ctx, owner, repo, "README.md", fileOpts) + if err != nil { + _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to create initial file", resp, err) + return nil, nil, fmt.Errorf("failed to create initial file: %w", err) + } + if resp != nil && resp.Body != nil { + defer func() { _ = resp.Body.Close() }() + } + + // Get the commit that was just created to use as base for remaining files + baseCommit, resp, err = client.Git.GetCommit(ctx, owner, repo, *createResp.Commit.SHA) + if err != nil { + _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get initial commit", resp, err) + return nil, nil, fmt.Errorf("failed to get initial commit: %w", err) + } + if resp != nil && resp.Body != nil { + defer func() { _ = resp.Body.Close() }() + } + + ref, resp, err = client.Git.GetRef(ctx, owner, repo, "refs/heads/"+defaultBranch) + if err != nil { + _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get final reference", resp, err) + return nil, nil, fmt.Errorf("failed to get branch reference after initial commit: %w", err) + } + if resp != nil && resp.Body != nil { + defer func() { _ = resp.Body.Close() }() + } + + return ref, baseCommit, nil +} + +// createReferenceFromDefaultBranch creates a new branch reference from the repository's default branch +func createReferenceFromDefaultBranch(ctx context.Context, client *github.Client, owner, repo, branch string) (*github.Reference, error) { + defaultRef, err := resolveDefaultBranch(ctx, client, owner, repo) + if err != nil { + _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to resolve default branch", nil, err) + return nil, fmt.Errorf("failed to resolve default branch: %w", err) + } + + // Create the new branch reference + createdRef, resp, err := client.Git.CreateRef(ctx, owner, repo, github.CreateRef{ + Ref: "refs/heads/" + branch, + SHA: *defaultRef.Object.SHA, + }) + if err != nil { + _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to create new branch reference", resp, err) + return nil, fmt.Errorf("failed to create new branch reference: %w", err) + } + if resp != nil && resp.Body != nil { + defer func() { _ = resp.Body.Close() }() + } + + return createdRef, nil +} + +// matchFiles searches for files in the Git tree that match the given path. +// It's used when GetContents fails or returns unexpected results. +func matchFiles(ctx context.Context, client *github.Client, owner, repo, ref, path string, rawOpts *raw.ContentOpts, rawAPIResponseCode int) (*mcp.CallToolResult, any, error) { + // Step 1: Get Git Tree recursively + tree, response, err := client.Git.GetTree(ctx, owner, repo, ref, true) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to get git tree", + response, + err, + ), nil, nil + } + defer func() { _ = response.Body.Close() }() + + // Step 2: Filter tree for matching paths + const maxMatchingFiles = 3 + matchingFiles := filterPaths(tree.Entries, path, maxMatchingFiles) + if len(matchingFiles) > 0 { + matchingFilesJSON, err := json.Marshal(matchingFiles) + if err != nil { + return utils.NewToolResultError(fmt.Sprintf("failed to marshal matching files: %s", err)), nil, nil + } + resolvedRefs, err := json.Marshal(rawOpts) + if err != nil { + return utils.NewToolResultError(fmt.Sprintf("failed to marshal resolved refs: %s", err)), nil, nil + } + if rawAPIResponseCode > 0 { + return utils.NewToolResultText(fmt.Sprintf("Resolved potential matches in the repository tree (resolved refs: %s, matching files: %s), but the content API returned an unexpected status code %d.", string(resolvedRefs), string(matchingFilesJSON), rawAPIResponseCode)), nil, nil + } + return utils.NewToolResultText(fmt.Sprintf("Resolved potential matches in the repository tree (resolved refs: %s, matching files: %s).", string(resolvedRefs), string(matchingFilesJSON))), nil, nil + } + return utils.NewToolResultError("Failed to get file contents. The path does not point to a file or directory, or the file does not exist in the repository."), nil, nil +} + +// filterPaths filters the entries in a GitHub tree to find paths that +// match the given suffix. +// maxResults limits the number of results returned to first maxResults entries, +// a maxResults of -1 means no limit. +// It returns a slice of strings containing the matching paths. +// Directories are returned with a trailing slash. +func filterPaths(entries []*github.TreeEntry, path string, maxResults int) []string { + // Remove trailing slash for matching purposes, but flag whether we + // only want directories. + dirOnly := false + if strings.HasSuffix(path, "/") { + dirOnly = true + path = strings.TrimSuffix(path, "/") + } + + matchedPaths := []string{} + for _, entry := range entries { + if len(matchedPaths) == maxResults { + break // Limit the number of results to maxResults + } + if dirOnly && entry.GetType() != "tree" { + continue // Skip non-directory entries if dirOnly is true + } + entryPath := entry.GetPath() + if entryPath == "" { + continue // Skip empty paths + } + if strings.HasSuffix(entryPath, path) { + if entry.GetType() == "tree" { + entryPath += "/" // Return directories with a trailing slash + } + matchedPaths = append(matchedPaths, entryPath) + } + } + return matchedPaths +} + +// looksLikeSHA returns true if the string appears to be a Git commit SHA. +// A SHA is a 40-character hexadecimal string. +func looksLikeSHA(s string) bool { + if len(s) != 40 { + return false + } + for _, c := range s { + if (c < '0' || c > '9') && (c < 'a' || c > 'f') && (c < 'A' || c > 'F') { + return false + } + } + return true +} + +// resolveGitReference takes a user-provided ref and sha and resolves them into a +// definitive commit SHA and its corresponding fully-qualified reference. +// +// The resolution logic follows a clear priority: +// +// 1. If a specific commit `sha` is provided, it takes precedence and is used directly, +// and all reference resolution is skipped. +// +// 1a. If `sha` is empty but `ref` looks like a commit SHA (40 hexadecimal characters), +// it is returned as-is without any API calls or reference resolution. +// +// 2. If no `sha` is provided and `ref` does not look like a SHA, the function resolves +// the `ref` string into a fully-qualified format (e.g., "refs/heads/main") by trying +// the following steps in order: +// a). **Empty Ref:** If `ref` is empty, the repository's default branch is used. +// b). **Fully-Qualified:** If `ref` already starts with "refs/", it's considered fully +// qualified and used as-is. +// c). **Partially-Qualified:** If `ref` starts with "heads/" or "tags/", it is +// prefixed with "refs/" to make it fully-qualified. +// d). **Short Name:** Otherwise, the `ref` is treated as a short name. The function +// first attempts to resolve it as a branch ("refs/heads/"). If that +// returns a 404 Not Found error, it then attempts to resolve it as a tag +// ("refs/tags/"). +// +// 3. **Final Lookup:** Once a fully-qualified ref is determined, a final API call +// is made to fetch that reference's definitive commit SHA. +// +// Any unexpected (non-404) errors during the resolution process are returned +// immediately. All API errors are logged with rich context to aid diagnostics. +func resolveGitReference(ctx context.Context, githubClient *github.Client, owner, repo, ref, sha string) (*raw.ContentOpts, bool, error) { + // 1) If SHA explicitly provided, it's the highest priority. + if sha != "" { + return &raw.ContentOpts{Ref: "", SHA: sha}, false, nil + } + + // 1a) If sha is empty but ref looks like a SHA, return it without changes + if looksLikeSHA(ref) { + return &raw.ContentOpts{Ref: "", SHA: ref}, false, nil + } + + originalRef := ref // Keep original ref for clearer error messages down the line. + + // 2) If no SHA is provided, we try to resolve the ref into a fully-qualified format. + var reference *github.Reference + var resp *github.Response + var err error + var fallbackUsed bool + + switch { + case originalRef == "": + // 2a) If ref is empty, determine the default branch. + reference, err = resolveDefaultBranch(ctx, githubClient, owner, repo) + if err != nil { + return nil, false, err // Error is already wrapped in resolveDefaultBranch. + } + ref = reference.GetRef() + case strings.HasPrefix(originalRef, "refs/"): + // 2b) Already fully qualified. The reference will be fetched at the end. + case strings.HasPrefix(originalRef, "heads/") || strings.HasPrefix(originalRef, "tags/"): + // 2c) Partially qualified. Make it fully qualified. + ref = "refs/" + originalRef + default: + // 2d) It's a short name, so we try to resolve it to either a branch or a tag. + branchRef := "refs/heads/" + originalRef + reference, resp, err = githubClient.Git.GetRef(ctx, owner, repo, branchRef) + + if err == nil { + ref = branchRef // It's a branch. + } else { + // The branch lookup failed. Check if it was a 404 Not Found error. + ghErr, isGhErr := err.(*github.ErrorResponse) + if isGhErr && ghErr.Response.StatusCode == http.StatusNotFound { + tagRef := "refs/tags/" + originalRef + reference, resp, err = githubClient.Git.GetRef(ctx, owner, repo, tagRef) + if err == nil { + ref = tagRef // It's a tag. + } else { + // The tag lookup also failed. Check if it was a 404 Not Found error. + ghErr2, isGhErr2 := err.(*github.ErrorResponse) + if isGhErr2 && ghErr2.Response.StatusCode == http.StatusNotFound { + if originalRef == "main" { + reference, err = resolveDefaultBranch(ctx, githubClient, owner, repo) + if err != nil { + return nil, false, err // Error is already wrapped in resolveDefaultBranch. + } + // Update ref to the actual default branch ref so the note can be generated + ref = reference.GetRef() + fallbackUsed = true + break + } + return nil, false, fmt.Errorf("could not resolve ref %q as a branch or a tag", originalRef) + } + + // The tag lookup failed for a different reason. + _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get reference (tag)", resp, err) + return nil, false, fmt.Errorf("failed to get reference for tag '%s': %w", originalRef, err) + } + } else { + // The branch lookup failed for a different reason. + _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get reference (branch)", resp, err) + return nil, false, fmt.Errorf("failed to get reference for branch '%s': %w", originalRef, err) + } + } + } + + if reference == nil { + reference, resp, err = githubClient.Git.GetRef(ctx, owner, repo, ref) + if err != nil { + if ref == "refs/heads/main" { + reference, err = resolveDefaultBranch(ctx, githubClient, owner, repo) + if err != nil { + return nil, false, err // Error is already wrapped in resolveDefaultBranch. + } + // Update ref to the actual default branch ref so the note can be generated + ref = reference.GetRef() + fallbackUsed = true + } else { + _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get final reference", resp, err) + return nil, false, fmt.Errorf("failed to get final reference for %q: %w", ref, err) + } + } + } + + sha = reference.GetObject().GetSHA() + return &raw.ContentOpts{Ref: ref, SHA: sha}, fallbackUsed, nil +} + +func resolveDefaultBranch(ctx context.Context, githubClient *github.Client, owner, repo string) (*github.Reference, error) { + repoInfo, resp, err := githubClient.Repositories.Get(ctx, owner, repo) + if err != nil { + _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get repository info", resp, err) + return nil, fmt.Errorf("failed to get repository info: %w", err) + } + + if resp != nil && resp.Body != nil { + _ = resp.Body.Close() + } + + defaultBranch := repoInfo.GetDefaultBranch() + + defaultRef, resp, err := githubClient.Git.GetRef(ctx, owner, repo, "heads/"+defaultBranch) + if err != nil { + _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get default branch reference", resp, err) + return nil, fmt.Errorf("failed to get default branch reference: %w", err) + } + + if resp != nil && resp.Body != nil { + defer func() { _ = resp.Body.Close() }() + } + + return defaultRef, nil +} diff --git a/pkg/github/repositories_test.go b/pkg/github/repositories_test.go index 8b5dab098..d91af8851 100644 --- a/pkg/github/repositories_test.go +++ b/pkg/github/repositories_test.go @@ -2,6 +2,7 @@ package github import ( "context" + "encoding/base64" "encoding/json" "net/http" "net/url" @@ -15,7 +16,6 @@ import ( "github.com/github/github-mcp-server/pkg/utils" "github.com/google/go-github/v79/github" "github.com/google/jsonschema-go/jsonschema" - "github.com/migueleliasweb/go-github-mock/src/mock" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -73,36 +73,25 @@ func Test_GetFileContents(t *testing.T) { }{ { name: "successful text content fetch", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposGitRefByOwnerByRepoByRef, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"ref": "refs/heads/main", "object": {"sha": ""}}`)) - }), - ), - mock.WithRequestMatchHandler( - mock.GetReposContentsByOwnerByRepoByPath, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - fileContent := &github.RepositoryContent{ - Name: github.Ptr("README.md"), - Path: github.Ptr("README.md"), - SHA: github.Ptr("abc123"), - Type: github.Ptr("file"), - } - contentBytes, _ := json.Marshal(fileContent) - _, _ = w.Write(contentBytes) - }), - ), - mock.WithRequestMatchHandler( - raw.GetRawReposContentsByOwnerByRepoByBranchByPath, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "text/markdown") - _, _ = w.Write(mockRawContent) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposGitRefByOwnerByRepoByRef: mockResponse(t, http.StatusOK, "{\"ref\": \"refs/heads/main\", \"object\": {\"sha\": \"\"}}"), + GetReposByOwnerByRepo: mockResponse(t, http.StatusOK, "{\"name\": \"repo\", \"default_branch\": \"main\"}"), + GetReposContentsByOwnerByRepoByPath: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + fileContent := &github.RepositoryContent{ + Name: github.Ptr("README.md"), + Path: github.Ptr("README.md"), + SHA: github.Ptr("abc123"), + Type: github.Ptr("file"), + } + contentBytes, _ := json.Marshal(fileContent) + _, _ = w.Write(contentBytes) + }, + GetRawReposContentsByOwnerByRepoByBranchByPath: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/markdown") + _, _ = w.Write(mockRawContent) + }, + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -118,36 +107,25 @@ func Test_GetFileContents(t *testing.T) { }, { name: "successful file blob content fetch", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposGitRefByOwnerByRepoByRef, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"ref": "refs/heads/main", "object": {"sha": ""}}`)) - }), - ), - mock.WithRequestMatchHandler( - mock.GetReposContentsByOwnerByRepoByPath, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - fileContent := &github.RepositoryContent{ - Name: github.Ptr("test.png"), - Path: github.Ptr("test.png"), - SHA: github.Ptr("def456"), - Type: github.Ptr("file"), - } - contentBytes, _ := json.Marshal(fileContent) - _, _ = w.Write(contentBytes) - }), - ), - mock.WithRequestMatchHandler( - raw.GetRawReposContentsByOwnerByRepoByBranchByPath, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "image/png") - _, _ = w.Write(mockRawContent) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposGitRefByOwnerByRepoByRef: mockResponse(t, http.StatusOK, "{\"ref\": \"refs/heads/main\", \"object\": {\"sha\": \"\"}}"), + GetReposByOwnerByRepo: mockResponse(t, http.StatusOK, "{\"name\": \"repo\", \"default_branch\": \"main\"}"), + GetReposContentsByOwnerByRepoByPath: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + fileContent := &github.RepositoryContent{ + Name: github.Ptr("test.png"), + Path: github.Ptr("test.png"), + SHA: github.Ptr("def456"), + Type: github.Ptr("file"), + } + contentBytes, _ := json.Marshal(fileContent) + _, _ = w.Write(contentBytes) + }, + GetRawReposContentsByOwnerByRepoByBranchByPath: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "image/png") + _, _ = w.Write(mockRawContent) + }, + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -163,36 +141,25 @@ func Test_GetFileContents(t *testing.T) { }, { name: "successful PDF file content fetch", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposGitRefByOwnerByRepoByRef, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"ref": "refs/heads/main", "object": {"sha": ""}}`)) - }), - ), - mock.WithRequestMatchHandler( - mock.GetReposContentsByOwnerByRepoByPath, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - fileContent := &github.RepositoryContent{ - Name: github.Ptr("document.pdf"), - Path: github.Ptr("document.pdf"), - SHA: github.Ptr("pdf123"), - Type: github.Ptr("file"), - } - contentBytes, _ := json.Marshal(fileContent) - _, _ = w.Write(contentBytes) - }), - ), - mock.WithRequestMatchHandler( - raw.GetRawReposContentsByOwnerByRepoByBranchByPath, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/pdf") - _, _ = w.Write(mockRawContent) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposGitRefByOwnerByRepoByRef: mockResponse(t, http.StatusOK, "{\"ref\": \"refs/heads/main\", \"object\": {\"sha\": \"\"}}"), + GetReposByOwnerByRepo: mockResponse(t, http.StatusOK, "{\"name\": \"repo\", \"default_branch\": \"main\"}"), + GetReposContentsByOwnerByRepoByPath: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + fileContent := &github.RepositoryContent{ + Name: github.Ptr("document.pdf"), + Path: github.Ptr("document.pdf"), + SHA: github.Ptr("pdf123"), + Type: github.Ptr("file"), + } + contentBytes, _ := json.Marshal(fileContent) + _, _ = w.Write(contentBytes) + }, + GetRawReposContentsByOwnerByRepoByBranchByPath: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/pdf") + _, _ = w.Write(mockRawContent) + }, + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -208,36 +175,16 @@ func Test_GetFileContents(t *testing.T) { }, { name: "successful directory content fetch", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposByOwnerByRepo, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"name": "repo", "default_branch": "main"}`)) - }), - ), - mock.WithRequestMatchHandler( - mock.GetReposGitRefByOwnerByRepoByRef, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"ref": "refs/heads/main", "object": {"sha": ""}}`)) - }), - ), - mock.WithRequestMatchHandler( - mock.GetReposContentsByOwnerByRepoByPath, - expectQueryParams(t, map[string]string{}).andThen( - mockResponse(t, http.StatusOK, mockDirContent), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposByOwnerByRepo: mockResponse(t, http.StatusOK, "{\"name\": \"repo\", \"default_branch\": \"main\"}"), + GetReposGitRefByOwnerByRepoByRef: mockResponse(t, http.StatusOK, "{\"ref\": \"refs/heads/main\", \"object\": {\"sha\": \"\"}}"), + GetReposContentsByOwnerByRepoByPath: expectQueryParams(t, map[string]string{}).andThen( + mockResponse(t, http.StatusOK, mockDirContent), ), - mock.WithRequestMatchHandler( - raw.GetRawReposContentsByOwnerByRepoByPath, - expectQueryParams(t, map[string]string{ - "branch": "main", - }).andThen( - mockResponse(t, http.StatusNotFound, nil), - ), + GetRawReposContentsByOwnerByRepoByPath: expectQueryParams(t, map[string]string{"branch": "main"}).andThen( + mockResponse(t, http.StatusNotFound, nil), ), - ), + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -248,36 +195,25 @@ func Test_GetFileContents(t *testing.T) { }, { name: "successful text content fetch with leading slash in path", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposGitRefByOwnerByRepoByRef, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"ref": "refs/heads/main", "object": {"sha": ""}}`)) - }), - ), - mock.WithRequestMatchHandler( - mock.GetReposContentsByOwnerByRepoByPath, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - fileContent := &github.RepositoryContent{ - Name: github.Ptr("README.md"), - Path: github.Ptr("README.md"), - SHA: github.Ptr("abc123"), - Type: github.Ptr("file"), - } - contentBytes, _ := json.Marshal(fileContent) - _, _ = w.Write(contentBytes) - }), - ), - mock.WithRequestMatchHandler( - raw.GetRawReposContentsByOwnerByRepoByBranchByPath, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "text/markdown") - _, _ = w.Write(mockRawContent) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposGitRefByOwnerByRepoByRef: mockResponse(t, http.StatusOK, "{\"ref\": \"refs/heads/main\", \"object\": {\"sha\": \"\"}}"), + GetReposByOwnerByRepo: mockResponse(t, http.StatusOK, "{\"name\": \"repo\", \"default_branch\": \"main\"}"), + GetReposContentsByOwnerByRepoByPath: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + fileContent := &github.RepositoryContent{ + Name: github.Ptr("README.md"), + Path: github.Ptr("README.md"), + SHA: github.Ptr("abc123"), + Type: github.Ptr("file"), + } + contentBytes, _ := json.Marshal(fileContent) + _, _ = w.Write(contentBytes) + }, + GetRawReposContentsByOwnerByRepoByBranchByPath: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/markdown") + _, _ = w.Write(mockRawContent) + }, + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -293,54 +229,82 @@ func Test_GetFileContents(t *testing.T) { }, { name: "successful text content fetch with note when ref falls back to default branch", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposByOwnerByRepo, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposByOwnerByRepo: mockResponse(t, http.StatusOK, "{\"name\": \"repo\", \"default_branch\": \"develop\"}"), + GetReposGitRefByOwnerByRepoByRef: func(w http.ResponseWriter, r *http.Request) { + path := strings.ReplaceAll(r.URL.Path, "%2F", "/") + switch { + case strings.Contains(path, "heads/main"): + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + case strings.Contains(path, "heads/develop"): w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"name": "repo", "default_branch": "develop"}`)) - }), - ), - mock.WithRequestMatchHandler( - mock.GetReposGitRefByOwnerByRepoByRef, - http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Request for "refs/heads/main" -> 404 (doesn't exist) - // Request for "refs/heads/develop" (default branch) -> 200 - switch { - case strings.Contains(r.URL.Path, "heads/main"): - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"message": "Not Found"}`)) - case strings.Contains(r.URL.Path, "heads/develop"): - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"ref": "refs/heads/develop", "object": {"sha": "abc123def456"}}`)) - default: - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"message": "Not Found"}`)) - } - }), - ), - mock.WithRequestMatchHandler( - mock.GetReposContentsByOwnerByRepoByPath, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"ref": "refs/heads/develop", "object": {"sha": "abc123def456", "type": "commit", "url": "https://api.github.com/repos/owner/repo/git/commits/abc123def456"}}`)) + default: + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + } + }, + "GET /repos/{owner}/{repo}/git/refs/{ref}": func(w http.ResponseWriter, r *http.Request) { + path := strings.ReplaceAll(r.URL.Path, "%2F", "/") + switch { + case strings.Contains(path, "heads/main"): + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + case strings.Contains(path, "heads/develop"): w.WriteHeader(http.StatusOK) - fileContent := &github.RepositoryContent{ - Name: github.Ptr("README.md"), - Path: github.Ptr("README.md"), - SHA: github.Ptr("abc123"), - Type: github.Ptr("file"), - } - contentBytes, _ := json.Marshal(fileContent) - _, _ = w.Write(contentBytes) - }), - ), - mock.WithRequestMatchHandler( - raw.GetRawReposContentsByOwnerByRepoBySHAByPath, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "text/markdown") - _, _ = w.Write(mockRawContent) - }), - ), - ), + _, _ = w.Write([]byte(`{"ref": "refs/heads/develop", "object": {"sha": "abc123def456", "type": "commit", "url": "https://api.github.com/repos/owner/repo/git/commits/abc123def456"}}`)) + default: + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + } + }, + "GET /repos/{owner}/{repo}/git/refs/{ref:.*}": func(w http.ResponseWriter, r *http.Request) { + path := strings.ReplaceAll(r.URL.Path, "%2F", "/") + switch { + case strings.Contains(path, "heads/main"): + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + case strings.Contains(path, "heads/develop"): + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ref": "refs/heads/develop", "object": {"sha": "abc123def456", "type": "commit", "url": "https://api.github.com/repos/owner/repo/git/commits/abc123def456"}}`)) + default: + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + } + }, + "GET /repos/owner/repo/git/ref/heads/main": func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + }, + "GET /repos/owner/repo/git/ref/heads/develop": func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ref": "refs/heads/develop", "object": {"sha": "abc123def456", "type": "commit", "url": "https://api.github.com/repos/owner/repo/git/commits/abc123def456"}}`)) + }, + GetReposContentsByOwnerByRepoByPath: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + fileContent := &github.RepositoryContent{ + Name: github.Ptr("README.md"), + Path: github.Ptr("README.md"), + SHA: github.Ptr("abc123"), + Type: github.Ptr("file"), + } + contentBytes, _ := json.Marshal(fileContent) + _, _ = w.Write(contentBytes) + }, + "GET /owner/repo/refs/heads/develop/README.md": func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/markdown") + _, _ = w.Write(mockRawContent) + }, + "GET /owner/repo/refs%2Fheads%2Fdevelop/README.md": func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/markdown") + _, _ = w.Write(mockRawContent) + }, + "GET /owner/repo/abc123def456/README.md": func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/markdown") + _, _ = w.Write(mockRawContent) + }, + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -357,29 +321,17 @@ func Test_GetFileContents(t *testing.T) { }, { name: "content fetch fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposGitRefByOwnerByRepoByRef, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"ref": "refs/heads/main", "object": {"sha": ""}}`)) - }), - ), - mock.WithRequestMatchHandler( - mock.GetReposContentsByOwnerByRepoByPath, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"message": "Not Found"}`)) - }), - ), - mock.WithRequestMatchHandler( - raw.GetRawReposContentsByOwnerByRepoByPath, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"message": "Not Found"}`)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposGitRefByOwnerByRepoByRef: mockResponse(t, http.StatusOK, "{\"ref\": \"refs/heads/main\", \"object\": {\"sha\": \"\"}}"), + GetReposContentsByOwnerByRepoByPath: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + }, + GetRawReposContentsByOwnerByRepoByPath: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + }, + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -491,12 +443,9 @@ func Test_ForkRepository(t *testing.T) { }{ { name: "successful repository fork", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PostReposForksByOwnerByRepo, - mockResponse(t, http.StatusAccepted, mockForkedRepo), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposForksByOwnerByRepo: mockResponse(t, http.StatusAccepted, mockForkedRepo), + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -506,15 +455,12 @@ func Test_ForkRepository(t *testing.T) { }, { name: "repository fork fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PostReposForksByOwnerByRepo, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusForbidden) - _, _ = w.Write([]byte(`{"message": "Forbidden"}`)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposForksByOwnerByRepo: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"message": "Forbidden"}`)) + }, + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -607,16 +553,11 @@ func Test_CreateBranch(t *testing.T) { }{ { name: "successful branch creation with from_branch", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetReposGitRefByOwnerByRepoByRef, - mockSourceRef, - ), - mock.WithRequestMatch( - mock.PostReposGitRefsByOwnerByRepo, - mockCreatedRef, - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposGitRefByOwnerByRepoByRef: mockResponse(t, http.StatusOK, mockSourceRef), + "GET /repos/owner/repo/git/ref/heads/main": mockResponse(t, http.StatusOK, mockSourceRef), + PostReposGitRefsByOwnerByRepo: mockResponse(t, http.StatusCreated, mockCreatedRef), + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -628,25 +569,17 @@ func Test_CreateBranch(t *testing.T) { }, { name: "successful branch creation with default branch", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetReposByOwnerByRepo, - mockRepo, - ), - mock.WithRequestMatch( - mock.GetReposGitRefByOwnerByRepoByRef, - mockSourceRef, - ), - mock.WithRequestMatchHandler( - mock.PostReposGitRefsByOwnerByRepo, - expectRequestBody(t, map[string]interface{}{ - "ref": "refs/heads/new-feature", - "sha": "abc123def456", - }).andThen( - mockResponse(t, http.StatusCreated, mockCreatedRef), - ), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposByOwnerByRepo: mockResponse(t, http.StatusOK, mockRepo), + GetReposGitRefByOwnerByRepoByRef: mockResponse(t, http.StatusOK, mockSourceRef), + "GET /repos/owner/repo/git/ref/heads/main": mockResponse(t, http.StatusOK, mockSourceRef), + PostReposGitRefsByOwnerByRepo: expectRequestBody(t, map[string]interface{}{ + "ref": "refs/heads/new-feature", + "sha": "abc123def456", + }).andThen( + mockResponse(t, http.StatusCreated, mockCreatedRef), + ), + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -657,15 +590,12 @@ func Test_CreateBranch(t *testing.T) { }, { name: "fail to get repository", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposByOwnerByRepo, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"message": "Repository not found"}`)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposByOwnerByRepo: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Repository not found"}`)) + }, + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "nonexistent-repo", @@ -676,15 +606,12 @@ func Test_CreateBranch(t *testing.T) { }, { name: "fail to get reference", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposGitRefByOwnerByRepoByRef, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"message": "Reference not found"}`)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposGitRefByOwnerByRepoByRef: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Reference not found"}`)) + }, + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -696,19 +623,14 @@ func Test_CreateBranch(t *testing.T) { }, { name: "fail to create branch", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetReposGitRefByOwnerByRepoByRef, - mockSourceRef, - ), - mock.WithRequestMatchHandler( - mock.PostReposGitRefsByOwnerByRepo, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusUnprocessableEntity) - _, _ = w.Write([]byte(`{"message": "Reference already exists"}`)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposGitRefByOwnerByRepoByRef: mockResponse(t, http.StatusOK, mockSourceRef), + "GET /repos/owner/repo/git/ref/heads/main": mockResponse(t, http.StatusOK, mockSourceRef), + PostReposGitRefsByOwnerByRepo: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnprocessableEntity) + _, _ = w.Write([]byte(`{"message": "Reference already exists"}`)) + }, + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -817,12 +739,9 @@ func Test_GetCommit(t *testing.T) { }{ { name: "successful commit fetch", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposCommitsByOwnerByRepoByRef, - mockResponse(t, http.StatusOK, mockCommit), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposCommitsByOwnerByRepoByRef: mockResponse(t, http.StatusOK, mockCommit), + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -833,15 +752,12 @@ func Test_GetCommit(t *testing.T) { }, { name: "commit fetch fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposCommitsByOwnerByRepoByRef, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"message": "Not Found"}`)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposCommitsByOwnerByRepoByRef: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + }, + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -999,12 +915,9 @@ func Test_ListCommits(t *testing.T) { }{ { name: "successful commits fetch with default params", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetReposCommitsByOwnerByRepo, - mockCommits, - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposCommitsByOwnerByRepo: mockResponse(t, http.StatusOK, mockCommits), + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -1014,19 +927,16 @@ func Test_ListCommits(t *testing.T) { }, { name: "successful commits fetch with branch", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposCommitsByOwnerByRepo, - expectQueryParams(t, map[string]string{ - "author": "username", - "sha": "main", - "page": "1", - "per_page": "30", - }).andThen( - mockResponse(t, http.StatusOK, mockCommits), - ), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposCommitsByOwnerByRepo: expectQueryParams(t, map[string]string{ + "author": "username", + "sha": "main", + "page": "1", + "per_page": "30", + }).andThen( + mockResponse(t, http.StatusOK, mockCommits), + ), + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -1038,17 +948,14 @@ func Test_ListCommits(t *testing.T) { }, { name: "successful commits fetch with pagination", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposCommitsByOwnerByRepo, - expectQueryParams(t, map[string]string{ - "page": "2", - "per_page": "10", - }).andThen( - mockResponse(t, http.StatusOK, mockCommits), - ), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposCommitsByOwnerByRepo: expectQueryParams(t, map[string]string{ + "page": "2", + "per_page": "10", + }).andThen( + mockResponse(t, http.StatusOK, mockCommits), + ), + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -1060,15 +967,12 @@ func Test_ListCommits(t *testing.T) { }, { name: "commits fetch fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposCommitsByOwnerByRepo, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"message": "Not Found"}`)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposCommitsByOwnerByRepo: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + }, + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "nonexistent-repo", @@ -1183,18 +1087,22 @@ func Test_CreateOrUpdateFile(t *testing.T) { }{ { name: "successful file creation", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PutReposContentsByOwnerByRepoByPath, - expectRequestBody(t, map[string]interface{}{ - "message": "Add example file", - "content": "IyBFeGFtcGxlCgpUaGlzIGlzIGFuIGV4YW1wbGUgZmlsZS4=", // Base64 encoded content - "branch": "main", - }).andThen( - mockResponse(t, http.StatusOK, mockFileResponse), - ), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PutReposContentsByOwnerByRepoByPath: expectRequestBody(t, map[string]interface{}{ + "message": "Add example file", + "content": "IyBFeGFtcGxlCgpUaGlzIGlzIGFuIGV4YW1wbGUgZmlsZS4=", // Base64 encoded content + "branch": "main", + }).andThen( + mockResponse(t, http.StatusOK, mockFileResponse), + ), + "PUT /repos/{owner}/{repo}/contents/{path:.*}": expectRequestBody(t, map[string]interface{}{ + "message": "Add example file", + "content": "IyBFeGFtcGxlCgpUaGlzIGlzIGFuIGV4YW1wbGUgZmlsZS4=", // Base64 encoded content + "branch": "main", + }).andThen( + mockResponse(t, http.StatusOK, mockFileResponse), + ), + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -1208,19 +1116,24 @@ func Test_CreateOrUpdateFile(t *testing.T) { }, { name: "successful file update with SHA", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PutReposContentsByOwnerByRepoByPath, - expectRequestBody(t, map[string]interface{}{ - "message": "Update example file", - "content": "IyBVcGRhdGVkIEV4YW1wbGUKClRoaXMgZmlsZSBoYXMgYmVlbiB1cGRhdGVkLg==", // Base64 encoded content - "branch": "main", - "sha": "abc123def456", - }).andThen( - mockResponse(t, http.StatusOK, mockFileResponse), - ), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PutReposContentsByOwnerByRepoByPath: expectRequestBody(t, map[string]interface{}{ + "message": "Update example file", + "content": "IyBVcGRhdGVkIEV4YW1wbGUKClRoaXMgZmlsZSBoYXMgYmVlbiB1cGRhdGVkLg==", // Base64 encoded content + "branch": "main", + "sha": "abc123def456", + }).andThen( + mockResponse(t, http.StatusOK, mockFileResponse), + ), + "PUT /repos/{owner}/{repo}/contents/{path:.*}": expectRequestBody(t, map[string]interface{}{ + "message": "Update example file", + "content": "IyBVcGRhdGVkIEV4YW1wbGUKClRoaXMgZmlsZSBoYXMgYmVlbiB1cGRhdGVkLg==", // Base64 encoded content + "branch": "main", + "sha": "abc123def456", + }).andThen( + mockResponse(t, http.StatusOK, mockFileResponse), + ), + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -1235,15 +1148,16 @@ func Test_CreateOrUpdateFile(t *testing.T) { }, { name: "file creation fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PutReposContentsByOwnerByRepoByPath, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusUnprocessableEntity) - _, _ = w.Write([]byte(`{"message": "Invalid request"}`)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PutReposContentsByOwnerByRepoByPath: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnprocessableEntity) + _, _ = w.Write([]byte(`{"message": "Invalid request"}`)) + }, + "PUT /repos/{owner}/{repo}/contents/{path:.*}": func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnprocessableEntity) + _, _ = w.Write([]byte(`{"message": "Invalid request"}`)) + }, + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -1257,35 +1171,42 @@ func Test_CreateOrUpdateFile(t *testing.T) { }, { name: "sha validation - current sha matches (304 Not Modified)", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{ - Pattern: "/repos/owner/repo/contents/docs/example.md", - Method: "HEAD", - }, - http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { - // Verify If-None-Match header is set correctly - ifNoneMatch := req.Header.Get("If-None-Match") - if ifNoneMatch == `"abc123def456"` { - w.WriteHeader(http.StatusNotModified) - } else { - w.WriteHeader(http.StatusOK) - w.Header().Set("ETag", `"abc123def456"`) - } - }), - ), - mock.WithRequestMatchHandler( - mock.PutReposContentsByOwnerByRepoByPath, - expectRequestBody(t, map[string]interface{}{ - "message": "Update example file", - "content": "IyBVcGRhdGVkIEV4YW1wbGUKClRoaXMgZmlsZSBoYXMgYmVlbiB1cGRhdGVkLg==", - "branch": "main", - "sha": "abc123def456", - }).andThen( - mockResponse(t, http.StatusOK, mockFileResponse), - ), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "HEAD /repos/owner/repo/contents/docs/example.md": func(w http.ResponseWriter, req *http.Request) { + ifNoneMatch := req.Header.Get("If-None-Match") + if ifNoneMatch == `"abc123def456"` { + w.WriteHeader(http.StatusNotModified) + } else { + w.WriteHeader(http.StatusOK) + w.Header().Set("ETag", `"abc123def456"`) + } + }, + "HEAD /repos/{owner}/{repo}/contents/{path:.*}": func(w http.ResponseWriter, req *http.Request) { + ifNoneMatch := req.Header.Get("If-None-Match") + if ifNoneMatch == `"abc123def456"` { + w.WriteHeader(http.StatusNotModified) + } else { + w.WriteHeader(http.StatusOK) + w.Header().Set("ETag", `"abc123def456"`) + } + }, + PutReposContentsByOwnerByRepoByPath: expectRequestBody(t, map[string]interface{}{ + "message": "Update example file", + "content": "IyBVcGRhdGVkIEV4YW1wbGUKClRoaXMgZmlsZSBoYXMgYmVlbiB1cGRhdGVkLg==", + "branch": "main", + "sha": "abc123def456", + }).andThen( + mockResponse(t, http.StatusOK, mockFileResponse), + ), + "PUT /repos/{owner}/{repo}/contents/{path:.*}": expectRequestBody(t, map[string]interface{}{ + "message": "Update example file", + "content": "IyBVcGRhdGVkIEV4YW1wbGUKClRoaXMgZmlsZSBoYXMgYmVlbiB1cGRhdGVkLg==", + "branch": "main", + "sha": "abc123def456", + }).andThen( + mockResponse(t, http.StatusOK, mockFileResponse), + ), + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -1300,19 +1221,16 @@ func Test_CreateOrUpdateFile(t *testing.T) { }, { name: "sha validation - stale sha detected (200 OK with different ETag)", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{ - Pattern: "/repos/owner/repo/contents/docs/example.md", - Method: "HEAD", - }, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - // SHA doesn't match - return 200 with current ETag - w.Header().Set("ETag", `"newsha999888"`) - w.WriteHeader(http.StatusOK) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "HEAD /repos/owner/repo/contents/docs/example.md": func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("ETag", `"newsha999888"`) + w.WriteHeader(http.StatusOK) + }, + "HEAD /repos/{owner}/{repo}/contents/{path:.*}": func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("ETag", `"newsha999888"`) + w.WriteHeader(http.StatusOK) + }, + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -1327,28 +1245,30 @@ func Test_CreateOrUpdateFile(t *testing.T) { }, { name: "sha validation - file doesn't exist (404), proceed with create", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{ - Pattern: "/repos/owner/repo/contents/docs/example.md", - Method: "HEAD", - }, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - }), - ), - mock.WithRequestMatchHandler( - mock.PutReposContentsByOwnerByRepoByPath, - expectRequestBody(t, map[string]interface{}{ - "message": "Create new file", - "content": "IyBOZXcgRmlsZQoKVGhpcyBpcyBhIG5ldyBmaWxlLg==", - "branch": "main", - "sha": "ignoredsha", // SHA is sent but GitHub API ignores it for new files - }).andThen( - mockResponse(t, http.StatusCreated, mockFileResponse), - ), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "HEAD /repos/owner/repo/contents/docs/example.md": func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + }, + PutReposContentsByOwnerByRepoByPath: expectRequestBody(t, map[string]interface{}{ + "message": "Create new file", + "content": "IyBOZXcgRmlsZQoKVGhpcyBpcyBhIG5ldyBmaWxlLg==", + "branch": "main", + "sha": "ignoredsha", // SHA is sent but GitHub API ignores it for new files + }).andThen( + mockResponse(t, http.StatusCreated, mockFileResponse), + ), + "HEAD /repos/{owner}/{repo}/contents/{path:.*}": func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + }, + "PUT /repos/{owner}/{repo}/contents/{path:.*}": expectRequestBody(t, map[string]interface{}{ + "message": "Create new file", + "content": "IyBOZXcgRmlsZQoKVGhpcyBpcyBhIG5ldyBmaWxlLg==", + "branch": "main", + "sha": "ignoredsha", // SHA is sent but GitHub API ignores it for new files + }).andThen( + mockResponse(t, http.StatusCreated, mockFileResponse), + ), + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -1363,29 +1283,32 @@ func Test_CreateOrUpdateFile(t *testing.T) { }, { name: "no sha provided - file exists, returns warning", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{ - Pattern: "/repos/owner/repo/contents/docs/example.md", - Method: "HEAD", - }, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("ETag", `"existing123"`) - w.WriteHeader(http.StatusOK) - }), - ), - mock.WithRequestMatchHandler( - mock.PutReposContentsByOwnerByRepoByPath, - expectRequestBody(t, map[string]interface{}{ - "message": "Update without SHA", - "content": "IyBVcGRhdGVkCgpVcGRhdGVkIHdpdGhvdXQgU0hBLg==", - "branch": "main", - "sha": "existing123", // SHA is automatically added from ETag - }).andThen( - mockResponse(t, http.StatusOK, mockFileResponse), - ), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "HEAD /repos/owner/repo/contents/docs/example.md": func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("ETag", `"existing123"`) + w.WriteHeader(http.StatusOK) + }, + PutReposContentsByOwnerByRepoByPath: expectRequestBody(t, map[string]interface{}{ + "message": "Update without SHA", + "content": "IyBVcGRhdGVkCgpVcGRhdGVkIHdpdGhvdXQgU0hBLg==", + "branch": "main", + "sha": "existing123", // SHA is automatically added from ETag + }).andThen( + mockResponse(t, http.StatusOK, mockFileResponse), + ), + "HEAD /repos/{owner}/{repo}/contents/{path:.*}": func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("ETag", `"existing123"`) + w.WriteHeader(http.StatusOK) + }, + "PUT /repos/{owner}/{repo}/contents/{path:.*}": expectRequestBody(t, map[string]interface{}{ + "message": "Update without SHA", + "content": "IyBVcGRhdGVkCgpVcGRhdGVkIHdpdGhvdXQgU0hBLg==", + "branch": "main", + "sha": "existing123", // SHA is automatically added from ETag + }).andThen( + mockResponse(t, http.StatusOK, mockFileResponse), + ), + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -1399,27 +1322,28 @@ func Test_CreateOrUpdateFile(t *testing.T) { }, { name: "no sha provided - file doesn't exist, no warning", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{ - Pattern: "/repos/owner/repo/contents/docs/example.md", - Method: "HEAD", - }, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - }), - ), - mock.WithRequestMatchHandler( - mock.PutReposContentsByOwnerByRepoByPath, - expectRequestBody(t, map[string]interface{}{ - "message": "Create new file", - "content": "IyBOZXcgRmlsZQoKQ3JlYXRlZCB3aXRob3V0IFNIQQ==", - "branch": "main", - }).andThen( - mockResponse(t, http.StatusCreated, mockFileResponse), - ), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "HEAD /repos/owner/repo/contents/docs/example.md": func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + }, + PutReposContentsByOwnerByRepoByPath: expectRequestBody(t, map[string]interface{}{ + "message": "Create new file", + "content": "IyBOZXcgRmlsZQoKQ3JlYXRlZCB3aXRob3V0IFNIQQ==", + "branch": "main", + }).andThen( + mockResponse(t, http.StatusCreated, mockFileResponse), + ), + "HEAD /repos/{owner}/{repo}/contents/{path:.*}": func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + }, + "PUT /repos/{owner}/{repo}/contents/{path:.*}": expectRequestBody(t, map[string]interface{}{ + "message": "Create new file", + "content": "IyBOZXcgRmlsZQoKQ3JlYXRlZCB3aXRob3V0IFNIQQ==", + "branch": "main", + }).andThen( + mockResponse(t, http.StatusCreated, mockFileResponse), + ), + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -1526,12 +1450,9 @@ func Test_CreateRepository(t *testing.T) { }{ { name: "successful repository creation with all parameters", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{ - Pattern: "/user/repos", - Method: "POST", - }, + mockedClient: NewMockedHTTPClient( + WithRequestMatchHandler( + EndpointPattern("POST /user/repos"), expectRequestBody(t, map[string]interface{}{ "name": "test-repo", "description": "Test repository", @@ -1553,12 +1474,9 @@ func Test_CreateRepository(t *testing.T) { }, { name: "successful repository creation in organization", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{ - Pattern: "/orgs/testorg/repos", - Method: "POST", - }, + mockedClient: NewMockedHTTPClient( + WithRequestMatchHandler( + EndpointPattern("POST /orgs/testorg/repos"), expectRequestBody(t, map[string]interface{}{ "name": "test-repo", "description": "Test repository", @@ -1581,12 +1499,9 @@ func Test_CreateRepository(t *testing.T) { }, { name: "successful repository creation with minimal parameters", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{ - Pattern: "/user/repos", - Method: "POST", - }, + mockedClient: NewMockedHTTPClient( + WithRequestMatchHandler( + EndpointPattern("POST /user/repos"), expectRequestBody(t, map[string]interface{}{ "name": "test-repo", "auto_init": false, @@ -1605,12 +1520,9 @@ func Test_CreateRepository(t *testing.T) { }, { name: "repository creation fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{ - Pattern: "/user/repos", - Method: "POST", - }, + mockedClient: NewMockedHTTPClient( + WithRequestMatchHandler( + EndpointPattern("POST /user/repos"), http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusUnprocessableEntity) _, _ = w.Write([]byte(`{"message": "Repository creation failed"}`)) @@ -1729,20 +1641,20 @@ func Test_PushFiles(t *testing.T) { }{ { name: "successful push of multiple files", - mockedClient: mock.NewMockedHTTPClient( + mockedClient: NewMockedHTTPClient( // Get branch reference - mock.WithRequestMatch( - mock.GetReposGitRefByOwnerByRepoByRef, + WithRequestMatch( + GetReposGitRefByOwnerByRepoByRef, mockRef, ), // Get commit - mock.WithRequestMatch( - mock.GetReposGitCommitsByOwnerByRepoByCommitSha, + WithRequestMatch( + GetReposGitCommitsByOwnerByRepoByCommitSHA, mockCommit, ), // Create tree - mock.WithRequestMatchHandler( - mock.PostReposGitTreesByOwnerByRepo, + WithRequestMatchHandler( + PostReposGitTreesByOwnerByRepo, expectRequestBody(t, map[string]interface{}{ "base_tree": "def456", "tree": []interface{}{ @@ -1764,8 +1676,8 @@ func Test_PushFiles(t *testing.T) { ), ), // Create commit - mock.WithRequestMatchHandler( - mock.PostReposGitCommitsByOwnerByRepo, + WithRequestMatchHandler( + PostReposGitCommitsByOwnerByRepo, expectRequestBody(t, map[string]interface{}{ "message": "Update multiple files", "tree": "ghi789", @@ -1775,8 +1687,8 @@ func Test_PushFiles(t *testing.T) { ), ), // Update reference - mock.WithRequestMatchHandler( - mock.PatchReposGitRefsByOwnerByRepoByRef, + WithRequestMatchHandler( + PatchReposGitRefsByOwnerByRepoByRef, expectRequestBody(t, map[string]interface{}{ "sha": "jkl012", "force": false, @@ -1806,7 +1718,7 @@ func Test_PushFiles(t *testing.T) { }, { name: "fails when files parameter is invalid", - mockedClient: mock.NewMockedHTTPClient( + mockedClient: NewMockedHTTPClient( // No requests expected ), requestArgs: map[string]interface{}{ @@ -1821,15 +1733,15 @@ func Test_PushFiles(t *testing.T) { }, { name: "fails when files contains object without path", - mockedClient: mock.NewMockedHTTPClient( + mockedClient: NewMockedHTTPClient( // Get branch reference - mock.WithRequestMatch( - mock.GetReposGitRefByOwnerByRepoByRef, + WithRequestMatch( + GetReposGitRefByOwnerByRepoByRef, mockRef, ), // Get commit - mock.WithRequestMatch( - mock.GetReposGitCommitsByOwnerByRepoByCommitSha, + WithRequestMatch( + GetReposGitCommitsByOwnerByRepoByCommitSHA, mockCommit, ), ), @@ -1849,15 +1761,15 @@ func Test_PushFiles(t *testing.T) { }, { name: "fails when files contains object without content", - mockedClient: mock.NewMockedHTTPClient( + mockedClient: NewMockedHTTPClient( // Get branch reference - mock.WithRequestMatch( - mock.GetReposGitRefByOwnerByRepoByRef, + WithRequestMatch( + GetReposGitRefByOwnerByRepoByRef, mockRef, ), // Get commit - mock.WithRequestMatch( - mock.GetReposGitCommitsByOwnerByRepoByCommitSha, + WithRequestMatch( + GetReposGitCommitsByOwnerByRepoByCommitSHA, mockCommit, ), ), @@ -1878,9 +1790,14 @@ func Test_PushFiles(t *testing.T) { }, { name: "fails to get branch reference", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposGitRefByOwnerByRepoByRef, + mockedClient: NewMockedHTTPClient( + WithRequestMatchHandler( + GetReposGitRefByOwnerByRepoByRef, + mockResponse(t, http.StatusNotFound, nil), + ), + // Mock Repositories.Get to fail when trying to create branch from default + WithRequestMatchHandler( + GetReposByOwnerByRepo, mockResponse(t, http.StatusNotFound, nil), ), ), @@ -1896,20 +1813,20 @@ func Test_PushFiles(t *testing.T) { }, "message": "Update file", }, - expectError: true, - expectedErrMsg: "failed to get branch reference", + expectError: false, + expectedErrMsg: "failed to create branch from default", }, { name: "fails to get base commit", - mockedClient: mock.NewMockedHTTPClient( + mockedClient: NewMockedHTTPClient( // Get branch reference - mock.WithRequestMatch( - mock.GetReposGitRefByOwnerByRepoByRef, + WithRequestMatch( + GetReposGitRefByOwnerByRepoByRef, mockRef, ), // Fail to get commit - mock.WithRequestMatchHandler( - mock.GetReposGitCommitsByOwnerByRepoByCommitSha, + WithRequestMatchHandler( + GetReposGitCommitsByOwnerByRepoByCommitSHA, mockResponse(t, http.StatusNotFound, nil), ), ), @@ -1930,20 +1847,20 @@ func Test_PushFiles(t *testing.T) { }, { name: "fails to create tree", - mockedClient: mock.NewMockedHTTPClient( + mockedClient: NewMockedHTTPClient( // Get branch reference - mock.WithRequestMatch( - mock.GetReposGitRefByOwnerByRepoByRef, + WithRequestMatch( + GetReposGitRefByOwnerByRepoByRef, mockRef, ), // Get commit - mock.WithRequestMatch( - mock.GetReposGitCommitsByOwnerByRepoByCommitSha, + WithRequestMatch( + GetReposGitCommitsByOwnerByRepoByCommitSHA, mockCommit, ), // Fail to create tree - mock.WithRequestMatchHandler( - mock.PostReposGitTreesByOwnerByRepo, + WithRequestMatchHandler( + PostReposGitTreesByOwnerByRepo, mockResponse(t, http.StatusInternalServerError, nil), ), ), @@ -1962,6 +1879,400 @@ func Test_PushFiles(t *testing.T) { expectError: true, expectedErrMsg: "failed to create tree", }, + { + name: "successful push to empty repository", + mockedClient: NewMockedHTTPClient( + // Get branch reference - first returns 409 for empty repo, second returns success after init + WithRequestMatchHandler( + GetReposGitRefByOwnerByRepoByRef, + func() http.HandlerFunc { + callCount := 0 + return func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + callCount++ + if callCount == 1 { + // First call: empty repo + w.WriteHeader(http.StatusConflict) + response := map[string]interface{}{ + "message": "Git Repository is empty.", + } + _ = json.NewEncoder(w).Encode(response) + } else { + // Second call: return the created reference + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(mockRef) + } + } + }(), + ), + // Mock Repositories.Get to return default branch for initialization + WithRequestMatch( + GetReposByOwnerByRepo, + &github.Repository{ + DefaultBranch: github.Ptr("main"), + }, + ), + // Create initial file using Contents API + WithRequestMatchHandler( + PutReposContentsByOwnerByRepoByPath, + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]interface{} + err := json.NewDecoder(r.Body).Decode(&body) + require.NoError(t, err) + require.Equal(t, "Initial commit", body["message"]) + require.Equal(t, "main", body["branch"]) + w.WriteHeader(http.StatusCreated) + response := &github.RepositoryContentResponse{ + Commit: github.Commit{SHA: github.Ptr("abc123")}, + } + b, _ := json.Marshal(response) + _, _ = w.Write(b) + }), + ), + // Get the commit after initialization + WithRequestMatch( + GetReposGitCommitsByOwnerByRepoByCommitSHA, + mockCommit, + ), + // Create tree + WithRequestMatch( + PostReposGitTreesByOwnerByRepo, + mockTree, + ), + // Create commit + WithRequestMatch( + PostReposGitCommitsByOwnerByRepo, + mockNewCommit, + ), + // Update reference + WithRequestMatch( + PatchReposGitRefsByOwnerByRepoByRef, + mockUpdatedRef, + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "branch": "main", + "files": []interface{}{ + map[string]interface{}{ + "path": "README.md", + "content": "# Initial README\n\nFirst commit to empty repository.", + }, + }, + "message": "Initial commit", + }, + expectError: false, + expectedRef: mockUpdatedRef, + }, + { + name: "successful push multiple files to empty repository", + mockedClient: NewMockedHTTPClient( + // Get branch reference - called twice: first for empty check, second after file creation + WithRequestMatchHandler( + GetReposGitRefByOwnerByRepoByRef, + func() http.HandlerFunc { + callCount := 0 + return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + callCount++ + if callCount == 1 { + // First call: returns 409 Conflict for empty repo + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusConflict) + response := map[string]interface{}{ + "message": "Git Repository is empty.", + } + _ = json.NewEncoder(w).Encode(response) + } else { + // Second call: returns the updated reference after first file creation + w.WriteHeader(http.StatusOK) + b, _ := json.Marshal(&github.Reference{ + Ref: github.Ptr("refs/heads/main"), + Object: &github.GitObject{SHA: github.Ptr("init456")}, + }) + _, _ = w.Write(b) + } + }) + }(), + ), + // Mock Repositories.Get to return default branch for initialization + WithRequestMatch( + GetReposByOwnerByRepo, + &github.Repository{ + DefaultBranch: github.Ptr("main"), + }, + ), + // Create initial empty README.md file using Contents API to initialize repo + WithRequestMatchHandler( + PutReposContentsByOwnerByRepoByPath, + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]interface{} + err := json.NewDecoder(r.Body).Decode(&body) + require.NoError(t, err) + require.Equal(t, "Initial commit", body["message"]) + require.Equal(t, "main", body["branch"]) + // Verify it's an empty file + expectedContent := base64.StdEncoding.EncodeToString([]byte("")) + require.Equal(t, expectedContent, body["content"]) + w.WriteHeader(http.StatusCreated) + response := &github.RepositoryContentResponse{ + Content: &github.RepositoryContent{ + SHA: github.Ptr("readme123"), + }, + Commit: github.Commit{ + SHA: github.Ptr("init456"), + Tree: &github.Tree{ + SHA: github.Ptr("tree456"), + }, + }, + } + b, _ := json.Marshal(response) + _, _ = w.Write(b) + }), + ), + // Get the commit to retrieve parent SHA + WithRequestMatchHandler( + GetReposGitCommitsByOwnerByRepoByCommitSHA, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + response := &github.Commit{ + SHA: github.Ptr("init456"), + Tree: &github.Tree{ + SHA: github.Ptr("tree456"), + }, + } + b, _ := json.Marshal(response) + _, _ = w.Write(b) + }), + ), + // Create tree with all user files + WithRequestMatchHandler( + PostReposGitTreesByOwnerByRepo, + expectRequestBody(t, map[string]interface{}{ + "base_tree": "tree456", + "tree": []interface{}{ + map[string]interface{}{ + "path": "README.md", + "mode": "100644", + "type": "blob", + "content": "# Project\n\nProject README", + }, + map[string]interface{}{ + "path": ".gitignore", + "mode": "100644", + "type": "blob", + "content": "node_modules/\n*.log\n", + }, + map[string]interface{}{ + "path": "src/main.js", + "mode": "100644", + "type": "blob", + "content": "console.log('Hello World');\n", + }, + }, + }).andThen( + mockResponse(t, http.StatusCreated, mockTree), + ), + ), + // Create commit with all user files + WithRequestMatchHandler( + PostReposGitCommitsByOwnerByRepo, + expectRequestBody(t, map[string]interface{}{ + "message": "Initial project setup", + "tree": "ghi789", + "parents": []interface{}{"init456"}, + }).andThen( + mockResponse(t, http.StatusCreated, mockNewCommit), + ), + ), + // Update reference + WithRequestMatchHandler( + PatchReposGitRefsByOwnerByRepoByRef, + expectRequestBody(t, map[string]interface{}{ + "sha": "jkl012", + "force": false, + }).andThen( + mockResponse(t, http.StatusOK, mockUpdatedRef), + ), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "branch": "main", + "files": []interface{}{ + map[string]interface{}{ + "path": "README.md", + "content": "# Project\n\nProject README", + }, + map[string]interface{}{ + "path": ".gitignore", + "content": "node_modules/\n*.log\n", + }, + map[string]interface{}{ + "path": "src/main.js", + "content": "console.log('Hello World');\n", + }, + }, + "message": "Initial project setup", + }, + expectError: false, + expectedRef: mockUpdatedRef, + }, + { + name: "fails to create initial file in empty repository", + mockedClient: NewMockedHTTPClient( + // Get branch reference returns 409 Conflict for empty repo + WithRequestMatchHandler( + GetReposGitRefByOwnerByRepoByRef, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusConflict) + response := map[string]interface{}{ + "message": "Git Repository is empty.", + } + _ = json.NewEncoder(w).Encode(response) + }), + ), + // Mock Repositories.Get to return default branch + WithRequestMatch( + GetReposByOwnerByRepo, + &github.Repository{ + DefaultBranch: github.Ptr("main"), + }, + ), + // Fail to create initial file using Contents API + WithRequestMatchHandler( + PutReposContentsByOwnerByRepoByPath, + mockResponse(t, http.StatusInternalServerError, nil), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "branch": "main", + "files": []interface{}{ + map[string]interface{}{ + "path": "README.md", + "content": "# README", + }, + }, + "message": "Initial commit", + }, + expectError: false, + expectedErrMsg: "failed to initialize repository", + }, + { + name: "fails to get reference after creating initial file in empty repository", + mockedClient: NewMockedHTTPClient( + // Get branch reference - called twice + WithRequestMatchHandler( + GetReposGitRefByOwnerByRepoByRef, + func() http.HandlerFunc { + callCount := 0 + return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + callCount++ + if callCount == 1 { + // First call: returns 409 Conflict for empty repo + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusConflict) + response := map[string]interface{}{ + "message": "Git Repository is empty.", + } + _ = json.NewEncoder(w).Encode(response) + } else { + // Second call: fails + w.WriteHeader(http.StatusInternalServerError) + } + }) + }(), + ), + // Mock Repositories.Get to return default branch + WithRequestMatch( + GetReposByOwnerByRepo, + &github.Repository{ + DefaultBranch: github.Ptr("main"), + }, + ), + // Create initial file using Contents API + WithRequestMatch( + PutReposContentsByOwnerByRepoByPath, + &github.RepositoryContentResponse{ + Content: &github.RepositoryContent{SHA: github.Ptr("readme123")}, + Commit: github.Commit{SHA: github.Ptr("init456")}, + }, + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "branch": "main", + "files": []interface{}{ + map[string]interface{}{ + "path": "README.md", + "content": "# README", + }, + }, + "message": "Initial commit", + }, + expectError: false, + expectedErrMsg: "failed to initialize repository", + }, + { + name: "fails to get commit in empty repository with multiple files", + mockedClient: NewMockedHTTPClient( + // Get branch reference returns 409 Conflict for empty repo + WithRequestMatchHandler( + GetReposGitRefByOwnerByRepoByRef, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusConflict) + response := map[string]interface{}{ + "message": "Git Repository is empty.", + } + _ = json.NewEncoder(w).Encode(response) + }), + ), + // Mock Repositories.Get to return default branch + WithRequestMatch( + GetReposByOwnerByRepo, + &github.Repository{ + DefaultBranch: github.Ptr("main"), + }, + ), + // Create initial file using Contents API + WithRequestMatch( + PutReposContentsByOwnerByRepoByPath, + &github.RepositoryContentResponse{ + Content: &github.RepositoryContent{SHA: github.Ptr("readme123")}, + Commit: github.Commit{SHA: github.Ptr("init456")}, + }, + ), + // Fail to get commit + WithRequestMatchHandler( + GetReposGitCommitsByOwnerByRepoByCommitSHA, + mockResponse(t, http.StatusInternalServerError, nil), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "branch": "main", + "files": []interface{}{ + map[string]interface{}{ + "path": "README.md", + "content": "# README", + }, + map[string]interface{}{ + "path": "LICENSE", + "content": "MIT", + }, + }, + "message": "Initial commit", + }, + expectError: false, + expectedErrMsg: "failed to initialize repository", + }, } for _, tc := range tests { @@ -2046,7 +2357,7 @@ func Test_ListBranches(t *testing.T) { tests := []struct { name string args map[string]interface{} - mockResponses []mock.MockBackendOption + mockResponses []MockBackendOption wantErr bool errContains string }{ @@ -2057,9 +2368,9 @@ func Test_ListBranches(t *testing.T) { "repo": "repo", "page": float64(2), }, - mockResponses: []mock.MockBackendOption{ - mock.WithRequestMatch( - mock.GetReposBranchesByOwnerByRepo, + mockResponses: []MockBackendOption{ + WithRequestMatch( + GetReposBranchesByOwnerByRepo, mockBranches, ), }, @@ -2070,7 +2381,7 @@ func Test_ListBranches(t *testing.T) { args: map[string]interface{}{ "repo": "repo", }, - mockResponses: []mock.MockBackendOption{}, + mockResponses: []MockBackendOption{}, wantErr: false, errContains: "missing required parameter: owner", }, @@ -2079,7 +2390,7 @@ func Test_ListBranches(t *testing.T) { args: map[string]interface{}{ "owner": "owner", }, - mockResponses: []mock.MockBackendOption{}, + mockResponses: []MockBackendOption{}, wantErr: false, errContains: "missing required parameter: repo", }, @@ -2088,7 +2399,7 @@ func Test_ListBranches(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Create mock client - mockClient := github.NewClient(mock.NewMockedHTTPClient(tt.mockResponses...)) + mockClient := github.NewClient(NewMockedHTTPClient(tt.mockResponses...)) deps := BaseDeps{ Client: mockClient, } @@ -2185,20 +2496,20 @@ func Test_DeleteFile(t *testing.T) { }{ { name: "successful file deletion using Git Data API", - mockedClient: mock.NewMockedHTTPClient( + mockedClient: NewMockedHTTPClient( // Get branch reference - mock.WithRequestMatch( - mock.GetReposGitRefByOwnerByRepoByRef, + WithRequestMatch( + GetReposGitRefByOwnerByRepoByRef, mockRef, ), // Get commit - mock.WithRequestMatch( - mock.GetReposGitCommitsByOwnerByRepoByCommitSha, + WithRequestMatch( + GetReposGitCommitsByOwnerByRepoByCommitSHA, mockCommit, ), // Create tree - mock.WithRequestMatchHandler( - mock.PostReposGitTreesByOwnerByRepo, + WithRequestMatchHandler( + PostReposGitTreesByOwnerByRepo, expectRequestBody(t, map[string]interface{}{ "base_tree": "def456", "tree": []interface{}{ @@ -2214,8 +2525,8 @@ func Test_DeleteFile(t *testing.T) { ), ), // Create commit - mock.WithRequestMatchHandler( - mock.PostReposGitCommitsByOwnerByRepo, + WithRequestMatchHandler( + PostReposGitCommitsByOwnerByRepo, expectRequestBody(t, map[string]interface{}{ "message": "Delete example file", "tree": "ghi789", @@ -2225,8 +2536,8 @@ func Test_DeleteFile(t *testing.T) { ), ), // Update reference - mock.WithRequestMatchHandler( - mock.PatchReposGitRefsByOwnerByRepoByRef, + WithRequestMatchHandler( + PatchReposGitRefsByOwnerByRepoByRef, expectRequestBody(t, map[string]interface{}{ "sha": "jkl012", "force": false, @@ -2252,9 +2563,9 @@ func Test_DeleteFile(t *testing.T) { }, { name: "file deletion fails - branch not found", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposGitRefByOwnerByRepoByRef, + mockedClient: NewMockedHTTPClient( + WithRequestMatchHandler( + GetReposGitRefByOwnerByRepoByRef, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNotFound) _, _ = w.Write([]byte(`{"message": "Reference not found"}`)) @@ -2362,9 +2673,9 @@ func Test_ListTags(t *testing.T) { }{ { name: "successful tags list", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposTagsByOwnerByRepo, + mockedClient: NewMockedHTTPClient( + WithRequestMatchHandler( + GetReposTagsByOwnerByRepo, expectPath( t, "/repos/owner/repo/tags", @@ -2382,9 +2693,9 @@ func Test_ListTags(t *testing.T) { }, { name: "list tags fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposTagsByOwnerByRepo, + mockedClient: NewMockedHTTPClient( + WithRequestMatchHandler( + GetReposTagsByOwnerByRepo, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusInternalServerError) _, _ = w.Write([]byte(`{"message": "Internal Server Error"}`)) @@ -2488,9 +2799,9 @@ func Test_GetTag(t *testing.T) { }{ { name: "successful tag retrieval", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposGitRefByOwnerByRepoByRef, + mockedClient: NewMockedHTTPClient( + WithRequestMatchHandler( + GetReposGitRefByOwnerByRepoByRef, expectPath( t, "/repos/owner/repo/git/ref/tags/v1.0.0", @@ -2498,8 +2809,8 @@ func Test_GetTag(t *testing.T) { mockResponse(t, http.StatusOK, mockTagRef), ), ), - mock.WithRequestMatchHandler( - mock.GetReposGitTagsByOwnerByRepoByTagSha, + WithRequestMatchHandler( + GetReposGitTagsByOwnerByRepoByTagSHA, expectPath( t, "/repos/owner/repo/git/tags/v1.0.0-tag-sha", @@ -2518,9 +2829,9 @@ func Test_GetTag(t *testing.T) { }, { name: "tag reference not found", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposGitRefByOwnerByRepoByRef, + mockedClient: NewMockedHTTPClient( + WithRequestMatchHandler( + GetReposGitRefByOwnerByRepoByRef, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNotFound) _, _ = w.Write([]byte(`{"message": "Reference does not exist"}`)) @@ -2537,13 +2848,13 @@ func Test_GetTag(t *testing.T) { }, { name: "tag object not found", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetReposGitRefByOwnerByRepoByRef, + mockedClient: NewMockedHTTPClient( + WithRequestMatch( + GetReposGitRefByOwnerByRepoByRef, mockTagRef, ), - mock.WithRequestMatchHandler( - mock.GetReposGitTagsByOwnerByRepoByTagSha, + WithRequestMatchHandler( + GetReposGitTagsByOwnerByRepoByTagSHA, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNotFound) _, _ = w.Write([]byte(`{"message": "Tag object does not exist"}`)) @@ -2641,9 +2952,9 @@ func Test_ListReleases(t *testing.T) { }{ { name: "successful releases list", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetReposReleasesByOwnerByRepo, + mockedClient: NewMockedHTTPClient( + WithRequestMatch( + GetReposReleasesByOwnerByRepo, mockReleases, ), ), @@ -2656,9 +2967,9 @@ func Test_ListReleases(t *testing.T) { }, { name: "releases list fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposReleasesByOwnerByRepo, + mockedClient: NewMockedHTTPClient( + WithRequestMatchHandler( + GetReposReleasesByOwnerByRepo, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNotFound) _, _ = w.Write([]byte(`{"message": "Not Found"}`)) @@ -2732,9 +3043,9 @@ func Test_GetLatestRelease(t *testing.T) { }{ { name: "successful latest release fetch", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetReposReleasesLatestByOwnerByRepo, + mockedClient: NewMockedHTTPClient( + WithRequestMatch( + GetReposReleasesLatestByOwnerByRepo, mockRelease, ), ), @@ -2747,9 +3058,9 @@ func Test_GetLatestRelease(t *testing.T) { }, { name: "latest release fetch fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposReleasesLatestByOwnerByRepo, + mockedClient: NewMockedHTTPClient( + WithRequestMatchHandler( + GetReposReleasesLatestByOwnerByRepo, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNotFound) _, _ = w.Write([]byte(`{"message": "Not Found"}`)) @@ -2829,9 +3140,9 @@ func Test_GetReleaseByTag(t *testing.T) { }{ { name: "successful release by tag fetch", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetReposReleasesTagsByOwnerByRepoByTag, + mockedClient: NewMockedHTTPClient( + WithRequestMatch( + GetReposReleasesTagsByOwnerByRepoByTag, mockRelease, ), ), @@ -2845,7 +3156,7 @@ func Test_GetReleaseByTag(t *testing.T) { }, { name: "missing owner parameter", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: NewMockedHTTPClient(), requestArgs: map[string]interface{}{ "repo": "repo", "tag": "v1.0.0", @@ -2855,7 +3166,7 @@ func Test_GetReleaseByTag(t *testing.T) { }, { name: "missing repo parameter", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: NewMockedHTTPClient(), requestArgs: map[string]interface{}{ "owner": "owner", "tag": "v1.0.0", @@ -2865,7 +3176,7 @@ func Test_GetReleaseByTag(t *testing.T) { }, { name: "missing tag parameter", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: NewMockedHTTPClient(), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -2875,9 +3186,9 @@ func Test_GetReleaseByTag(t *testing.T) { }, { name: "release by tag not found", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposReleasesTagsByOwnerByRepoByTag, + mockedClient: NewMockedHTTPClient( + WithRequestMatchHandler( + GetReposReleasesTagsByOwnerByRepoByTag, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNotFound) _, _ = w.Write([]byte(`{"message": "Not Found"}`)) @@ -2894,9 +3205,9 @@ func Test_GetReleaseByTag(t *testing.T) { }, { name: "server error", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposReleasesTagsByOwnerByRepoByTag, + mockedClient: NewMockedHTTPClient( + WithRequestMatchHandler( + GetReposReleasesTagsByOwnerByRepoByTag, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusInternalServerError) _, _ = w.Write([]byte(`{"message": "Internal Server Error"}`)) @@ -3143,7 +3454,7 @@ func Test_resolveGitReference(t *testing.T) { sha: "123sha456", mockSetup: func() *http.Client { // No API calls should be made when SHA is provided - return mock.NewMockedHTTPClient() + return NewMockedHTTPClient() }, expectedOutput: &raw.ContentOpts{ SHA: "123sha456", @@ -3155,16 +3466,16 @@ func Test_resolveGitReference(t *testing.T) { ref: "", sha: "", mockSetup: func() *http.Client { - return mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposByOwnerByRepo, + return NewMockedHTTPClient( + WithRequestMatchHandler( + GetReposByOwnerByRepo, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{"name": "repo", "default_branch": "main"}`)) }), ), - mock.WithRequestMatchHandler( - mock.GetReposGitRefByOwnerByRepoByRef, + WithRequestMatchHandler( + GetReposGitRefByOwnerByRepoByRef, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Contains(t, r.URL.Path, "/git/ref/heads/main") w.WriteHeader(http.StatusOK) @@ -3184,9 +3495,9 @@ func Test_resolveGitReference(t *testing.T) { ref: "refs/heads/feature-branch", sha: "", mockSetup: func() *http.Client { - return mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposGitRefByOwnerByRepoByRef, + return NewMockedHTTPClient( + WithRequestMatchHandler( + GetReposGitRefByOwnerByRepoByRef, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Contains(t, r.URL.Path, "/git/ref/heads/feature-branch") w.WriteHeader(http.StatusOK) @@ -3206,9 +3517,9 @@ func Test_resolveGitReference(t *testing.T) { ref: "main", sha: "", mockSetup: func() *http.Client { - return mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposGitRefByOwnerByRepoByRef, + return NewMockedHTTPClient( + WithRequestMatchHandler( + GetReposGitRefByOwnerByRepoByRef, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if strings.Contains(r.URL.Path, "/git/ref/heads/main") { w.WriteHeader(http.StatusOK) @@ -3232,9 +3543,9 @@ func Test_resolveGitReference(t *testing.T) { ref: "v1.0.0", sha: "", mockSetup: func() *http.Client { - return mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposGitRefByOwnerByRepoByRef, + return NewMockedHTTPClient( + WithRequestMatchHandler( + GetReposGitRefByOwnerByRepoByRef, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case strings.Contains(r.URL.Path, "/git/ref/heads/v1.0.0"): @@ -3262,9 +3573,9 @@ func Test_resolveGitReference(t *testing.T) { ref: "heads/feature-branch", sha: "", mockSetup: func() *http.Client { - return mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposGitRefByOwnerByRepoByRef, + return NewMockedHTTPClient( + WithRequestMatchHandler( + GetReposGitRefByOwnerByRepoByRef, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Contains(t, r.URL.Path, "/git/ref/heads/feature-branch") w.WriteHeader(http.StatusOK) @@ -3284,9 +3595,9 @@ func Test_resolveGitReference(t *testing.T) { ref: "tags/v1.0.0", sha: "", mockSetup: func() *http.Client { - return mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposGitRefByOwnerByRepoByRef, + return NewMockedHTTPClient( + WithRequestMatchHandler( + GetReposGitRefByOwnerByRepoByRef, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Contains(t, r.URL.Path, "/git/ref/tags/v1.0.0") w.WriteHeader(http.StatusOK) @@ -3306,9 +3617,9 @@ func Test_resolveGitReference(t *testing.T) { ref: "nonexistent", sha: "", mockSetup: func() *http.Client { - return mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposGitRefByOwnerByRepoByRef, + return NewMockedHTTPClient( + WithRequestMatchHandler( + GetReposGitRefByOwnerByRepoByRef, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { // Both branch and tag attempts should return 404 w.WriteHeader(http.StatusNotFound) @@ -3325,9 +3636,9 @@ func Test_resolveGitReference(t *testing.T) { ref: "refs/pull/123/head", sha: "", mockSetup: func() *http.Client { - return mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposGitRefByOwnerByRepoByRef, + return NewMockedHTTPClient( + WithRequestMatchHandler( + GetReposGitRefByOwnerByRepoByRef, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Contains(t, r.URL.Path, "/git/ref/pull/123/head") w.WriteHeader(http.StatusOK) @@ -3348,7 +3659,7 @@ func Test_resolveGitReference(t *testing.T) { sha: "", mockSetup: func() *http.Client { // No API calls should be made when ref looks like SHA - return mock.NewMockedHTTPClient() + return NewMockedHTTPClient() }, expectedOutput: &raw.ContentOpts{ SHA: "abc123def456abc123def456abc123def456abc1", @@ -3456,12 +3767,12 @@ func Test_ListStarredRepositories(t *testing.T) { }{ { name: "successful list for authenticated user", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetUserStarred, + mockedClient: NewMockedHTTPClient( + WithRequestMatchHandler( + GetUserStarred, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) - _, _ = w.Write(mock.MustMarshal(mockStarredRepos)) + _, _ = w.Write(MustMarshal(mockStarredRepos)) }), ), ), @@ -3471,12 +3782,12 @@ func Test_ListStarredRepositories(t *testing.T) { }, { name: "successful list for specific user", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetUsersStarredByUsername, + mockedClient: NewMockedHTTPClient( + WithRequestMatchHandler( + GetUsersStarredByUsername, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) - _, _ = w.Write(mock.MustMarshal(mockStarredRepos)) + _, _ = w.Write(MustMarshal(mockStarredRepos)) }), ), ), @@ -3488,9 +3799,9 @@ func Test_ListStarredRepositories(t *testing.T) { }, { name: "list fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetUserStarred, + mockedClient: NewMockedHTTPClient( + WithRequestMatchHandler( + GetUserStarred, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNotFound) _, _ = w.Write([]byte(`{"message": "Not Found"}`)) @@ -3570,9 +3881,9 @@ func Test_StarRepository(t *testing.T) { }{ { name: "successful star", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PutUserStarredByOwnerByRepo, + mockedClient: NewMockedHTTPClient( + WithRequestMatchHandler( + PutUserStarredByOwnerByRepo, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) }), @@ -3586,9 +3897,9 @@ func Test_StarRepository(t *testing.T) { }, { name: "star fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PutUserStarredByOwnerByRepo, + mockedClient: NewMockedHTTPClient( + WithRequestMatchHandler( + PutUserStarredByOwnerByRepo, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNotFound) _, _ = w.Write([]byte(`{"message": "Not Found"}`)) @@ -3661,9 +3972,9 @@ func Test_UnstarRepository(t *testing.T) { }{ { name: "successful unstar", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.DeleteUserStarredByOwnerByRepo, + mockedClient: NewMockedHTTPClient( + WithRequestMatchHandler( + DeleteUserStarredByOwnerByRepo, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) }), @@ -3677,9 +3988,9 @@ func Test_UnstarRepository(t *testing.T) { }, { name: "unstar fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.DeleteUserStarredByOwnerByRepo, + mockedClient: NewMockedHTTPClient( + WithRequestMatchHandler( + DeleteUserStarredByOwnerByRepo, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNotFound) _, _ = w.Write([]byte(`{"message": "Not Found"}`)) diff --git a/pkg/github/repository_resource.go b/pkg/github/repository_resource.go index ee43e9d04..28ce63b46 100644 --- a/pkg/github/repository_resource.go +++ b/pkg/github/repository_resource.go @@ -102,15 +102,16 @@ func GetRepositoryResourcePrContent(t translations.TranslationHelperFunc) invent // repositoryResourceContentsHandlerFunc returns a ResourceHandlerFunc that creates handlers on-demand. func repositoryResourceContentsHandlerFunc(resourceURITemplate *uritemplate.Template) inventory.ResourceHandlerFunc { - return func(deps any) mcp.ResourceHandler { - d := deps.(ToolDependencies) - return RepositoryResourceContentsHandler(d, resourceURITemplate) + return func(_ any) mcp.ResourceHandler { + return RepositoryResourceContentsHandler(resourceURITemplate) } } // RepositoryResourceContentsHandler returns a handler function for repository content requests. -func RepositoryResourceContentsHandler(deps ToolDependencies, resourceURITemplate *uritemplate.Template) mcp.ResourceHandler { +// It retrieves ToolDependencies from the context at call time via MustDepsFromContext. +func RepositoryResourceContentsHandler(resourceURITemplate *uritemplate.Template) mcp.ResourceHandler { return func(ctx context.Context, request *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { + deps := MustDepsFromContext(ctx) // Match the URI to extract parameters uriValues := resourceURITemplate.Match(request.Params.URI) if uriValues == nil { diff --git a/pkg/github/repository_resource_completions.go b/pkg/github/repository_resource_completions.go index aeb2d88a6..c70cfe948 100644 --- a/pkg/github/repository_resource_completions.go +++ b/pkg/github/repository_resource_completions.go @@ -33,8 +33,10 @@ func RepositoryResourceCompletionHandler(getClient GetClientFn) func(ctx context argName := req.Params.Argument.Name argValue := req.Params.Argument.Value - resolved := req.Params.Context.Arguments - if resolved == nil { + var resolved map[string]string + if req.Params.Context != nil && req.Params.Context.Arguments != nil { + resolved = req.Params.Context.Arguments + } else { resolved = map[string]string{} } diff --git a/pkg/github/repository_resource_test.go b/pkg/github/repository_resource_test.go index b55b821af..a3b3ca754 100644 --- a/pkg/github/repository_resource_test.go +++ b/pkg/github/repository_resource_test.go @@ -26,7 +26,7 @@ func Test_repositoryResourceContents(t *testing.T) { name string mockedClient *http.Client uri string - handlerFn func(deps ToolDependencies) mcp.ResourceHandler + handlerFn func() mcp.ResourceHandler expectedResponseType resourceResponseType expectError string expectedResult *mcp.ReadResourceResult @@ -41,8 +41,8 @@ func Test_repositoryResourceContents(t *testing.T) { }), }), uri: "repo:///repo/contents/README.md", - handlerFn: func(deps ToolDependencies) mcp.ResourceHandler { - return RepositoryResourceContentsHandler(deps, repositoryResourceContentURITemplate) + handlerFn: func() mcp.ResourceHandler { + return RepositoryResourceContentsHandler(repositoryResourceContentURITemplate) }, expectedResponseType: resourceResponseTypeText, // Ignored as error is expected expectError: "owner is required", @@ -57,8 +57,8 @@ func Test_repositoryResourceContents(t *testing.T) { }), }), uri: "repo://owner//refs/heads/main/contents/README.md", - handlerFn: func(deps ToolDependencies) mcp.ResourceHandler { - return RepositoryResourceContentsHandler(deps, repositoryResourceBranchContentURITemplate) + handlerFn: func() mcp.ResourceHandler { + return RepositoryResourceContentsHandler(repositoryResourceBranchContentURITemplate) }, expectedResponseType: resourceResponseTypeText, // Ignored as error is expected expectError: "repo is required", @@ -73,8 +73,8 @@ func Test_repositoryResourceContents(t *testing.T) { }), }), uri: "repo://owner/repo/contents/data.png", - handlerFn: func(deps ToolDependencies) mcp.ResourceHandler { - return RepositoryResourceContentsHandler(deps, repositoryResourceContentURITemplate) + handlerFn: func() mcp.ResourceHandler { + return RepositoryResourceContentsHandler(repositoryResourceContentURITemplate) }, expectedResponseType: resourceResponseTypeBlob, expectedResult: &mcp.ReadResourceResult{ @@ -94,8 +94,8 @@ func Test_repositoryResourceContents(t *testing.T) { }), }), uri: "repo://owner/repo/contents/README.md", - handlerFn: func(deps ToolDependencies) mcp.ResourceHandler { - return RepositoryResourceContentsHandler(deps, repositoryResourceContentURITemplate) + handlerFn: func() mcp.ResourceHandler { + return RepositoryResourceContentsHandler(repositoryResourceContentURITemplate) }, expectedResponseType: resourceResponseTypeText, expectedResult: &mcp.ReadResourceResult{ @@ -117,8 +117,8 @@ func Test_repositoryResourceContents(t *testing.T) { }), }), uri: "repo://owner/repo/contents/pkg/github/actions.go", - handlerFn: func(deps ToolDependencies) mcp.ResourceHandler { - return RepositoryResourceContentsHandler(deps, repositoryResourceContentURITemplate) + handlerFn: func() mcp.ResourceHandler { + return RepositoryResourceContentsHandler(repositoryResourceContentURITemplate) }, expectedResponseType: resourceResponseTypeText, expectedResult: &mcp.ReadResourceResult{ @@ -138,8 +138,8 @@ func Test_repositoryResourceContents(t *testing.T) { }), }), uri: "repo://owner/repo/refs/heads/main/contents/README.md", - handlerFn: func(deps ToolDependencies) mcp.ResourceHandler { - return RepositoryResourceContentsHandler(deps, repositoryResourceBranchContentURITemplate) + handlerFn: func() mcp.ResourceHandler { + return RepositoryResourceContentsHandler(repositoryResourceBranchContentURITemplate) }, expectedResponseType: resourceResponseTypeText, expectedResult: &mcp.ReadResourceResult{ @@ -159,8 +159,8 @@ func Test_repositoryResourceContents(t *testing.T) { }), }), uri: "repo://owner/repo/refs/tags/v1.0.0/contents/README.md", - handlerFn: func(deps ToolDependencies) mcp.ResourceHandler { - return RepositoryResourceContentsHandler(deps, repositoryResourceTagContentURITemplate) + handlerFn: func() mcp.ResourceHandler { + return RepositoryResourceContentsHandler(repositoryResourceTagContentURITemplate) }, expectedResponseType: resourceResponseTypeText, expectedResult: &mcp.ReadResourceResult{ @@ -180,8 +180,8 @@ func Test_repositoryResourceContents(t *testing.T) { }), }), uri: "repo://owner/repo/sha/abc123/contents/README.md", - handlerFn: func(deps ToolDependencies) mcp.ResourceHandler { - return RepositoryResourceContentsHandler(deps, repositoryResourceCommitContentURITemplate) + handlerFn: func() mcp.ResourceHandler { + return RepositoryResourceContentsHandler(repositoryResourceCommitContentURITemplate) }, expectedResponseType: resourceResponseTypeText, expectedResult: &mcp.ReadResourceResult{ @@ -206,8 +206,8 @@ func Test_repositoryResourceContents(t *testing.T) { }), }), uri: "repo://owner/repo/refs/pull/42/head/contents/README.md", - handlerFn: func(deps ToolDependencies) mcp.ResourceHandler { - return RepositoryResourceContentsHandler(deps, repositoryResourcePrContentURITemplate) + handlerFn: func() mcp.ResourceHandler { + return RepositoryResourceContentsHandler(repositoryResourcePrContentURITemplate) }, expectedResponseType: resourceResponseTypeText, expectedResult: &mcp.ReadResourceResult{ @@ -226,8 +226,8 @@ func Test_repositoryResourceContents(t *testing.T) { }), }), uri: "repo://owner/repo/contents/nonexistent.md", - handlerFn: func(deps ToolDependencies) mcp.ResourceHandler { - return RepositoryResourceContentsHandler(deps, repositoryResourceContentURITemplate) + handlerFn: func() mcp.ResourceHandler { + return RepositoryResourceContentsHandler(repositoryResourceContentURITemplate) }, expectedResponseType: resourceResponseTypeText, // Ignored as error is expected expectError: "404 Not Found", @@ -242,7 +242,8 @@ func Test_repositoryResourceContents(t *testing.T) { Client: client, RawClient: mockRawClient, } - handler := tc.handlerFn(deps) + ctx := ContextWithDeps(context.Background(), deps) + handler := tc.handlerFn() request := &mcp.ReadResourceRequest{ Params: &mcp.ReadResourceParams{ @@ -250,7 +251,7 @@ func Test_repositoryResourceContents(t *testing.T) { }, } - resp, err := handler(context.TODO(), request) + resp, err := handler(ctx, request) if tc.expectError != "" { require.ErrorContains(t, err, tc.expectError) diff --git a/pkg/github/scope_filter.go b/pkg/github/scope_filter.go new file mode 100644 index 000000000..42f8e98b0 --- /dev/null +++ b/pkg/github/scope_filter.go @@ -0,0 +1,64 @@ +package github + +import ( + "context" + + "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/scopes" +) + +// repoScopesSet contains scopes that grant access to repository content. +// Tools requiring only these scopes work on public repos without any token scope, +// so we don't filter them out even if the token lacks repo/public_repo. +var repoScopesSet = map[string]bool{ + string(scopes.Repo): true, + string(scopes.PublicRepo): true, +} + +// onlyRequiresRepoScopes returns true if all of the tool's accepted scopes +// are repo-related scopes (repo, public_repo). Such tools work on public +// repositories without needing any scope. +func onlyRequiresRepoScopes(acceptedScopes []string) bool { + if len(acceptedScopes) == 0 { + return false + } + for _, scope := range acceptedScopes { + if !repoScopesSet[scope] { + return false + } + } + return true +} + +// CreateToolScopeFilter creates an inventory.ToolFilter that filters tools +// based on the token's OAuth scopes. +// +// For PATs (Personal Access Tokens), we cannot issue OAuth scope challenges +// like we can with OAuth apps. Instead, we hide tools that require scopes +// the token doesn't have. +// +// This is the recommended way to filter tools for stdio servers where the +// token is known at startup and won't change during the session. +// +// The filter returns true (include tool) if: +// - The tool has no scope requirements (AcceptedScopes is empty) +// - The tool is read-only and only requires repo/public_repo scopes (works on public repos) +// - The token has at least one of the tool's accepted scopes +// +// Example usage: +// +// tokenScopes, err := scopes.FetchTokenScopes(ctx, token) +// if err != nil { +// // Handle error - maybe skip filtering +// } +// filter := github.CreateToolScopeFilter(tokenScopes) +// inventory := github.NewInventory(t).WithFilter(filter).Build() +func CreateToolScopeFilter(tokenScopes []string) inventory.ToolFilter { + return func(_ context.Context, tool *inventory.ServerTool) (bool, error) { + // Read-only tools requiring only repo/public_repo work on public repos without any scope + if tool.Tool.Annotations != nil && tool.Tool.Annotations.ReadOnlyHint && onlyRequiresRepoScopes(tool.AcceptedScopes) { + return true, nil + } + return scopes.HasRequiredScopes(tokenScopes, tool.AcceptedScopes), nil + } +} diff --git a/pkg/github/scope_filter_test.go b/pkg/github/scope_filter_test.go new file mode 100644 index 000000000..451d1a64e --- /dev/null +++ b/pkg/github/scope_filter_test.go @@ -0,0 +1,190 @@ +package github + +import ( + "context" + "testing" + + "github.com/github/github-mcp-server/pkg/inventory" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCreateToolScopeFilter(t *testing.T) { + // Create test tools with various scope requirements + toolNoScopes := &inventory.ServerTool{ + Tool: mcp.Tool{Name: "no_scopes_tool"}, + AcceptedScopes: nil, + } + + toolEmptyScopes := &inventory.ServerTool{ + Tool: mcp.Tool{Name: "empty_scopes_tool"}, + AcceptedScopes: []string{}, + } + + toolRepoScope := &inventory.ServerTool{ + Tool: mcp.Tool{Name: "repo_tool"}, + AcceptedScopes: []string{"repo"}, + } + + toolRepoScopeReadOnly := &inventory.ServerTool{ + Tool: mcp.Tool{ + Name: "repo_tool_readonly", + Annotations: &mcp.ToolAnnotations{ReadOnlyHint: true}, + }, + AcceptedScopes: []string{"repo"}, + } + + toolPublicRepoScope := &inventory.ServerTool{ + Tool: mcp.Tool{Name: "public_repo_tool"}, + AcceptedScopes: []string{"public_repo", "repo"}, // repo is parent, also accepted + } + + toolPublicRepoScopeReadOnly := &inventory.ServerTool{ + Tool: mcp.Tool{ + Name: "public_repo_tool_readonly", + Annotations: &mcp.ToolAnnotations{ReadOnlyHint: true}, + }, + AcceptedScopes: []string{"public_repo", "repo"}, + } + + toolGistScope := &inventory.ServerTool{ + Tool: mcp.Tool{Name: "gist_tool"}, + AcceptedScopes: []string{"gist"}, + } + + toolMultiScope := &inventory.ServerTool{ + Tool: mcp.Tool{Name: "multi_scope_tool"}, + AcceptedScopes: []string{"repo", "admin:org"}, + } + + tests := []struct { + name string + tokenScopes []string + tool *inventory.ServerTool + expected bool + }{ + { + name: "tool with no scopes is always visible", + tokenScopes: []string{}, + tool: toolNoScopes, + expected: true, + }, + { + name: "tool with empty scopes is always visible", + tokenScopes: []string{"repo"}, + tool: toolEmptyScopes, + expected: true, + }, + { + name: "token with exact scope can see tool", + tokenScopes: []string{"repo"}, + tool: toolRepoScope, + expected: true, + }, + { + name: "token with parent scope can see child-scoped tool", + tokenScopes: []string{"repo"}, + tool: toolPublicRepoScope, + expected: true, + }, + { + name: "token missing required scope cannot see tool", + tokenScopes: []string{"gist"}, + tool: toolRepoScope, + expected: false, + }, + { + name: "token with unrelated scope cannot see tool", + tokenScopes: []string{"repo"}, + tool: toolGistScope, + expected: false, + }, + { + name: "token with one of multiple accepted scopes can see tool", + tokenScopes: []string{"admin:org"}, + tool: toolMultiScope, + expected: true, + }, + { + name: "empty token scopes cannot see scoped tools", + tokenScopes: []string{}, + tool: toolRepoScope, + expected: false, + }, + { + name: "empty token scopes CAN see read-only repo tools (public repos)", + tokenScopes: []string{}, + tool: toolRepoScopeReadOnly, + expected: true, + }, + { + name: "empty token scopes CAN see read-only public_repo tools", + tokenScopes: []string{}, + tool: toolPublicRepoScopeReadOnly, + expected: true, + }, + { + name: "token with multiple scopes where one matches", + tokenScopes: []string{"gist", "repo"}, + tool: toolPublicRepoScope, + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + filter := CreateToolScopeFilter(tt.tokenScopes) + result, err := filter(context.Background(), tt.tool) + + require.NoError(t, err) + assert.Equal(t, tt.expected, result, "filter result should match expected") + }) + } +} + +func TestCreateToolScopeFilter_Integration(t *testing.T) { + // Test integration with inventory builder + tools := []inventory.ServerTool{ + { + Tool: mcp.Tool{Name: "public_tool"}, + Toolset: inventory.ToolsetMetadata{ID: "test"}, + AcceptedScopes: nil, // No scopes required + }, + { + Tool: mcp.Tool{Name: "repo_tool"}, + Toolset: inventory.ToolsetMetadata{ID: "test"}, + AcceptedScopes: []string{"repo"}, + }, + { + Tool: mcp.Tool{Name: "gist_tool"}, + Toolset: inventory.ToolsetMetadata{ID: "test"}, + AcceptedScopes: []string{"gist"}, + }, + } + + // Create filter for token with only "repo" scope + filter := CreateToolScopeFilter([]string{"repo"}) + + // Build inventory with the filter + inv := inventory.NewBuilder(). + SetTools(tools). + WithToolsets([]string{"test"}). + WithFilter(filter). + Build() + + // Get available tools + availableTools := inv.AvailableTools(context.Background()) + + // Should see public_tool and repo_tool, but not gist_tool + assert.Len(t, availableTools, 2) + + toolNames := make([]string, len(availableTools)) + for i, tool := range availableTools { + toolNames[i] = tool.Tool.Name + } + + assert.Contains(t, toolNames, "public_tool") + assert.Contains(t, toolNames, "repo_tool") + assert.NotContains(t, toolNames, "gist_tool") +} diff --git a/pkg/github/search.go b/pkg/github/search.go index 9a8b971e2..552fbfe78 100644 --- a/pkg/github/search.go +++ b/pkg/github/search.go @@ -9,6 +9,7 @@ import ( ghErrors "github.com/github/github-mcp-server/pkg/errors" "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" "github.com/google/go-github/v79/github" @@ -56,6 +57,7 @@ func SearchRepositories(t translations.TranslationHelperFunc) inventory.ServerTo }, InputSchema: schema, }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { query, err := RequiredParam[string](args, "query") if err != nil { @@ -198,6 +200,7 @@ func SearchCode(t translations.TranslationHelperFunc) inventory.ServerTool { }, InputSchema: schema, }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { query, err := RequiredParam[string](args, "query") if err != nil { @@ -379,6 +382,7 @@ func SearchUsers(t translations.TranslationHelperFunc) inventory.ServerTool { }, InputSchema: schema, }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { return userOrOrgHandler(ctx, "user", deps, args) }, @@ -420,6 +424,7 @@ func SearchOrgs(t translations.TranslationHelperFunc) inventory.ServerTool { }, InputSchema: schema, }, + []scopes.Scope{scopes.ReadOrg}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { return userOrOrgHandler(ctx, "org", deps, args) }, diff --git a/pkg/github/search_test.go b/pkg/github/search_test.go index be1b26714..e15758c3e 100644 --- a/pkg/github/search_test.go +++ b/pkg/github/search_test.go @@ -10,7 +10,6 @@ import ( "github.com/github/github-mcp-server/pkg/translations" "github.com/google/go-github/v79/github" "github.com/google/jsonschema-go/jsonschema" - "github.com/migueleliasweb/go-github-mock/src/mock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -67,20 +66,17 @@ func Test_SearchRepositories(t *testing.T) { }{ { name: "successful repository search", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchRepositories, - expectQueryParams(t, map[string]string{ - "q": "golang test", - "sort": "stars", - "order": "desc", - "page": "2", - "per_page": "10", - }).andThen( - mockResponse(t, http.StatusOK, mockSearchResult), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchRepositories: expectQueryParams(t, map[string]string{ + "q": "golang test", + "sort": "stars", + "order": "desc", + "page": "2", + "per_page": "10", + }).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), ), - ), + }), requestArgs: map[string]interface{}{ "query": "golang test", "sort": "stars", @@ -93,18 +89,15 @@ func Test_SearchRepositories(t *testing.T) { }, { name: "repository search with default pagination", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchRepositories, - expectQueryParams(t, map[string]string{ - "q": "golang test", - "page": "1", - "per_page": "30", - }).andThen( - mockResponse(t, http.StatusOK, mockSearchResult), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchRepositories: expectQueryParams(t, map[string]string{ + "q": "golang test", + "page": "1", + "per_page": "30", + }).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), ), - ), + }), requestArgs: map[string]interface{}{ "query": "golang test", }, @@ -113,15 +106,12 @@ func Test_SearchRepositories(t *testing.T) { }, { name: "search fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchRepositories, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusBadRequest) - _, _ = w.Write([]byte(`{"message": "Invalid query"}`)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchRepositories: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"message": "Invalid query"}`)) + }), + }), requestArgs: map[string]interface{}{ "query": "invalid:query", }, @@ -194,18 +184,15 @@ func Test_SearchRepositories_FullOutput(t *testing.T) { }, } - mockedClient := mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchRepositories, - expectQueryParams(t, map[string]string{ - "q": "golang test", - "page": "1", - "per_page": "30", - }).andThen( - mockResponse(t, http.StatusOK, mockSearchResult), - ), + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchRepositories: expectQueryParams(t, map[string]string{ + "q": "golang test", + "page": "1", + "per_page": "30", + }).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), ), - ) + }) client := github.NewClient(mockedClient) serverTool := SearchRepositories(translations.NullTranslationHelper) @@ -291,20 +278,17 @@ func Test_SearchCode(t *testing.T) { }{ { name: "successful code search with all parameters", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchCode, - expectQueryParams(t, map[string]string{ - "q": "fmt.Println language:go", - "sort": "indexed", - "order": "desc", - "page": "1", - "per_page": "30", - }).andThen( - mockResponse(t, http.StatusOK, mockSearchResult), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchCode: expectQueryParams(t, map[string]string{ + "q": "fmt.Println language:go", + "sort": "indexed", + "order": "desc", + "page": "1", + "per_page": "30", + }).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), ), - ), + }), requestArgs: map[string]interface{}{ "query": "fmt.Println language:go", "sort": "indexed", @@ -317,18 +301,15 @@ func Test_SearchCode(t *testing.T) { }, { name: "code search with minimal parameters", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchCode, - expectQueryParams(t, map[string]string{ - "q": "fmt.Println language:go", - "page": "1", - "per_page": "30", - }).andThen( - mockResponse(t, http.StatusOK, mockSearchResult), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchCode: expectQueryParams(t, map[string]string{ + "q": "fmt.Println language:go", + "page": "1", + "per_page": "30", + }).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), ), - ), + }), requestArgs: map[string]interface{}{ "query": "fmt.Println language:go", }, @@ -337,15 +318,12 @@ func Test_SearchCode(t *testing.T) { }, { name: "search code fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchCode, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusBadRequest) - _, _ = w.Write([]byte(`{"message": "Validation Failed"}`)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchCode: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"message": "Validation Failed"}`)) + }), + }), requestArgs: map[string]interface{}{ "query": "invalid:query", }, @@ -451,20 +429,17 @@ func Test_SearchUsers(t *testing.T) { }{ { name: "successful users search with all parameters", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchUsers, - expectQueryParams(t, map[string]string{ - "q": "type:user location:finland language:go", - "sort": "followers", - "order": "desc", - "page": "1", - "per_page": "30", - }).andThen( - mockResponse(t, http.StatusOK, mockSearchResult), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchUsers: expectQueryParams(t, map[string]string{ + "q": "type:user location:finland language:go", + "sort": "followers", + "order": "desc", + "page": "1", + "per_page": "30", + }).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), ), - ), + }), requestArgs: map[string]interface{}{ "query": "location:finland language:go", "sort": "followers", @@ -477,18 +452,15 @@ func Test_SearchUsers(t *testing.T) { }, { name: "users search with minimal parameters", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchUsers, - expectQueryParams(t, map[string]string{ - "q": "type:user location:finland language:go", - "page": "1", - "per_page": "30", - }).andThen( - mockResponse(t, http.StatusOK, mockSearchResult), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchUsers: expectQueryParams(t, map[string]string{ + "q": "type:user location:finland language:go", + "page": "1", + "per_page": "30", + }).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), ), - ), + }), requestArgs: map[string]interface{}{ "query": "location:finland language:go", }, @@ -497,18 +469,15 @@ func Test_SearchUsers(t *testing.T) { }, { name: "query with existing type:user filter - no duplication", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchUsers, - expectQueryParams(t, map[string]string{ - "q": "type:user location:seattle followers:>100", - "page": "1", - "per_page": "30", - }).andThen( - mockResponse(t, http.StatusOK, mockSearchResult), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchUsers: expectQueryParams(t, map[string]string{ + "q": "type:user location:seattle followers:>100", + "page": "1", + "per_page": "30", + }).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), ), - ), + }), requestArgs: map[string]interface{}{ "query": "type:user location:seattle followers:>100", }, @@ -517,18 +486,15 @@ func Test_SearchUsers(t *testing.T) { }, { name: "complex query with existing type:user filter and OR operators", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchUsers, - expectQueryParams(t, map[string]string{ - "q": "type:user (location:seattle OR location:california) followers:>50", - "page": "1", - "per_page": "30", - }).andThen( - mockResponse(t, http.StatusOK, mockSearchResult), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchUsers: expectQueryParams(t, map[string]string{ + "q": "type:user (location:seattle OR location:california) followers:>50", + "page": "1", + "per_page": "30", + }).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), ), - ), + }), requestArgs: map[string]interface{}{ "query": "type:user (location:seattle OR location:california) followers:>50", }, @@ -537,15 +503,12 @@ func Test_SearchUsers(t *testing.T) { }, { name: "search users fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchUsers, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusBadRequest) - _, _ = w.Write([]byte(`{"message": "Validation Failed"}`)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchUsers: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"message": "Validation Failed"}`)) + }), + }), requestArgs: map[string]interface{}{ "query": "invalid:query", }, @@ -652,18 +615,15 @@ func Test_SearchOrgs(t *testing.T) { }{ { name: "successful org search", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchUsers, - expectQueryParams(t, map[string]string{ - "q": "type:org github", - "page": "1", - "per_page": "30", - }).andThen( - mockResponse(t, http.StatusOK, mockSearchResult), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchUsers: expectQueryParams(t, map[string]string{ + "q": "type:org github", + "page": "1", + "per_page": "30", + }).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), ), - ), + }), requestArgs: map[string]interface{}{ "query": "github", }, @@ -672,18 +632,15 @@ func Test_SearchOrgs(t *testing.T) { }, { name: "query with existing type:org filter - no duplication", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchUsers, - expectQueryParams(t, map[string]string{ - "q": "type:org location:california followers:>1000", - "page": "1", - "per_page": "30", - }).andThen( - mockResponse(t, http.StatusOK, mockSearchResult), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchUsers: expectQueryParams(t, map[string]string{ + "q": "type:org location:california followers:>1000", + "page": "1", + "per_page": "30", + }).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), ), - ), + }), requestArgs: map[string]interface{}{ "query": "type:org location:california followers:>1000", }, @@ -692,18 +649,15 @@ func Test_SearchOrgs(t *testing.T) { }, { name: "complex query with existing type:org filter and OR operators", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchUsers, - expectQueryParams(t, map[string]string{ - "q": "type:org (location:seattle OR location:california OR location:newyork) repos:>10", - "page": "1", - "per_page": "30", - }).andThen( - mockResponse(t, http.StatusOK, mockSearchResult), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchUsers: expectQueryParams(t, map[string]string{ + "q": "type:org (location:seattle OR location:california OR location:newyork) repos:>10", + "page": "1", + "per_page": "30", + }).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), ), - ), + }), requestArgs: map[string]interface{}{ "query": "type:org (location:seattle OR location:california OR location:newyork) repos:>10", }, @@ -712,15 +666,12 @@ func Test_SearchOrgs(t *testing.T) { }, { name: "org search fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchUsers, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusBadRequest) - _, _ = w.Write([]byte(`{"message": "Validation Failed"}`)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchUsers: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"message": "Validation Failed"}`)) + }), + }), requestArgs: map[string]interface{}{ "query": "invalid:query", }, diff --git a/pkg/github/secret_scanning.go b/pkg/github/secret_scanning.go index 0de5166ba..fa60021e5 100644 --- a/pkg/github/secret_scanning.go +++ b/pkg/github/secret_scanning.go @@ -9,6 +9,7 @@ import ( ghErrors "github.com/github/github-mcp-server/pkg/errors" "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" "github.com/google/go-github/v79/github" @@ -45,6 +46,7 @@ func GetSecretScanningAlert(t translations.TranslationHelperFunc) inventory.Serv Required: []string{"owner", "repo", "alertNumber"}, }, }, + []scopes.Scope{scopes.SecurityEvents}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { @@ -131,6 +133,7 @@ func ListSecretScanningAlerts(t translations.TranslationHelperFunc) inventory.Se Required: []string{"owner", "repo"}, }, }, + []scopes.Scope{scopes.SecurityEvents}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { diff --git a/pkg/github/security_advisories.go b/pkg/github/security_advisories.go index f898de61d..7bdb978cd 100644 --- a/pkg/github/security_advisories.go +++ b/pkg/github/security_advisories.go @@ -9,6 +9,7 @@ import ( ghErrors "github.com/github/github-mcp-server/pkg/errors" "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" "github.com/google/go-github/v79/github" @@ -83,6 +84,7 @@ func ListGlobalSecurityAdvisories(t translations.TranslationHelperFunc) inventor }, }, }, + []scopes.Scope{scopes.SecurityEvents}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { client, err := deps.GetClient(ctx) if err != nil { @@ -246,6 +248,7 @@ func ListRepositorySecurityAdvisories(t translations.TranslationHelperFunc) inve Required: []string{"owner", "repo"}, }, }, + []scopes.Scope{scopes.SecurityEvents}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { @@ -330,6 +333,7 @@ func GetGlobalSecurityAdvisory(t translations.TranslationHelperFunc) inventory.S Required: []string{"ghsaId"}, }, }, + []scopes.Scope{scopes.SecurityEvents}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { client, err := deps.GetClient(ctx) if err != nil { @@ -401,6 +405,7 @@ func ListOrgRepositorySecurityAdvisories(t translations.TranslationHelperFunc) i Required: []string{"org"}, }, }, + []scopes.Scope{scopes.SecurityEvents}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { org, err := RequiredParam[string](args, "org") if err != nil { diff --git a/pkg/github/tools.go b/pkg/github/tools.go index f6d4afa80..b15c4fc9a 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -279,6 +279,11 @@ func AllTools(t translations.TranslationHelperFunc) []inventory.ServerTool { DeleteProjectItem(t), UpdateProjectItem(t), + // Consolidated project tools (enabled via feature flag) + ProjectsList(t), + ProjectsGet(t), + ProjectsWrite(t), + // Label tools GetLabel(t), GetLabelForLabelsToolset(t), diff --git a/pkg/inventory/builder.go b/pkg/inventory/builder.go index a0ed2baee..0400c2a24 100644 --- a/pkg/inventory/builder.go +++ b/pkg/inventory/builder.go @@ -149,11 +149,14 @@ func (b *Builder) Build() *Inventory { if len(b.additionalTools) > 0 { r.additionalTools = make(map[string]bool, len(b.additionalTools)) for _, name := range b.additionalTools { - // Resolve deprecated aliases to canonical names + // Always include the original name - this handles the case where + // the tool exists but is controlled by a feature flag that's OFF. + r.additionalTools[name] = true + // Also include the canonical name if this is a deprecated alias. + // This handles the case where the feature flag is ON and only + // the new consolidated tool is available. if canonical, isAlias := b.deprecatedAliases[name]; isAlias { r.additionalTools[canonical] = true - } else { - r.additionalTools[name] = true } } } diff --git a/pkg/inventory/filters.go b/pkg/inventory/filters.go index 991001a64..533bba552 100644 --- a/pkg/inventory/filters.go +++ b/pkg/inventory/filters.go @@ -178,33 +178,29 @@ func (r *Inventory) AvailablePrompts(ctx context.Context) []ServerPrompt { // filterToolsByName returns tools matching the given name, checking deprecated aliases. // Uses linear scan - optimized for single-lookup per-request scenarios (ForMCPRequest). +// Returns ALL tools matching the name to support feature-flagged tool variants +// (e.g., GetJobLogs and ActionsGetJobLogs both use name "get_job_logs" but are +// controlled by different feature flags). func (r *Inventory) filterToolsByName(name string) []ServerTool { - // First check for exact match + var result []ServerTool + // Check for exact matches - multiple tools may share the same name with different feature flags for i := range r.tools { if r.tools[i].Tool.Name == name { - return []ServerTool{r.tools[i]} + result = append(result, r.tools[i]) } } + if len(result) > 0 { + return result + } // Check if name is a deprecated alias if canonical, isAlias := r.deprecatedAliases[name]; isAlias { for i := range r.tools { if r.tools[i].Tool.Name == canonical { - return []ServerTool{r.tools[i]} + result = append(result, r.tools[i]) } } } - return []ServerTool{} -} - -// filterResourcesByURI returns resource templates matching the given URI pattern. -// Uses linear scan - optimized for single-lookup per-request scenarios (ForMCPRequest). -func (r *Inventory) filterResourcesByURI(uri string) []ServerResourceTemplate { - for i := range r.resourceTemplates { - if r.resourceTemplates[i].Template.URITemplate == uri { - return []ServerResourceTemplate{r.resourceTemplates[i]} - } - } - return []ServerResourceTemplate{} + return result } // filterPromptsByName returns prompts matching the given name. diff --git a/pkg/inventory/registry.go b/pkg/inventory/registry.go index f3691e38a..885617b43 100644 --- a/pkg/inventory/registry.go +++ b/pkg/inventory/registry.go @@ -91,7 +91,7 @@ const ( // - MCPMethodToolsList: All available tools (no resources/prompts) // - MCPMethodToolsCall: Only the named tool // - MCPMethodResourcesList, MCPMethodResourcesTemplatesList: All available resources (no tools/prompts) -// - MCPMethodResourcesRead: Only the named resource template +// - MCPMethodResourcesRead: All resources (SDK handles URI template matching) // - MCPMethodPromptsList: All available prompts (no tools/resources) // - MCPMethodPromptsGet: Only the named prompt // - Unknown methods: Empty (no items registered) @@ -134,10 +134,8 @@ func (r *Inventory) ForMCPRequest(method string, itemName string) *Inventory { case MCPMethodResourcesList, MCPMethodResourcesTemplatesList: result.tools, result.prompts = nil, nil case MCPMethodResourcesRead: + // Keep all resources registered - SDK handles URI template matching internally result.tools, result.prompts = nil, nil - if itemName != "" { - result.resourceTemplates = r.filterResourcesByURI(itemName) - } case MCPMethodPromptsList: result.tools, result.resourceTemplates = nil, nil case MCPMethodPromptsGet: diff --git a/pkg/inventory/registry_test.go b/pkg/inventory/registry_test.go index 41e94b8d9..136f8e523 100644 --- a/pkg/inventory/registry_test.go +++ b/pkg/inventory/registry_test.go @@ -775,17 +775,15 @@ func TestForMCPRequest_ResourcesRead(t *testing.T) { } reg := NewBuilder().SetResources(resources).WithToolsets([]string{"all"}).Build() - filtered := reg.ForMCPRequest(MCPMethodResourcesRead, "repo://{owner}/{repo}") + // Pass a concrete URI - all resources remain registered, SDK handles matching + filtered := reg.ForMCPRequest(MCPMethodResourcesRead, "repo://owner/repo") + // All resources should be available - SDK handles URI template matching internally available := filtered.AvailableResourceTemplates(context.Background()) - if len(available) != 1 { - t.Fatalf("Expected 1 resource for resources/read, got %d", len(available)) - } - if available[0].Template.URITemplate != "repo://{owner}/{repo}" { - t.Errorf("Expected URI template 'repo://{owner}/{repo}', got %q", available[0].Template.URITemplate) + if len(available) != 2 { + t.Fatalf("Expected 2 resources for resources/read (SDK handles matching), got %d", len(available)) } } - func TestForMCPRequest_PromptsList(t *testing.T) { tools := []ServerTool{ mockTool("tool1", "repos", true), @@ -1643,3 +1641,102 @@ func TestFilteringOrder(t *testing.T) { } } } + +func TestForMCPRequest_ToolsCall_FeatureFlaggedVariants(t *testing.T) { + // Simulate the get_job_logs scenario: two tools with the same name but different feature flags + // - "get_job_logs" with FeatureFlagDisable (available when flag is OFF) + // - "get_job_logs" with FeatureFlagEnable (available when flag is ON) + tools := []ServerTool{ + mockToolWithFlags("get_job_logs", "actions", true, "", "consolidated_flag"), // disabled when flag is ON + mockToolWithFlags("get_job_logs", "actions", true, "consolidated_flag", ""), // enabled when flag is ON + mockTool("other_tool", "actions", true), + } + + // Test 1: Flag is OFF - first tool variant should be available + regFlagOff := NewBuilder(). + SetTools(tools). + WithToolsets([]string{"all"}). + Build() + filteredOff := regFlagOff.ForMCPRequest(MCPMethodToolsCall, "get_job_logs") + availableOff := filteredOff.AvailableTools(context.Background()) + if len(availableOff) != 1 { + t.Fatalf("Flag OFF: Expected 1 tool, got %d", len(availableOff)) + } + if availableOff[0].FeatureFlagDisable != "consolidated_flag" { + t.Errorf("Flag OFF: Expected tool with FeatureFlagDisable, got FeatureFlagEnable=%q, FeatureFlagDisable=%q", + availableOff[0].FeatureFlagEnable, availableOff[0].FeatureFlagDisable) + } + + // Test 2: Flag is ON - second tool variant should be available + checker := func(_ context.Context, flag string) (bool, error) { + return flag == "consolidated_flag", nil + } + regFlagOn := NewBuilder(). + SetTools(tools). + WithToolsets([]string{"all"}). + WithFeatureChecker(checker). + Build() + filteredOn := regFlagOn.ForMCPRequest(MCPMethodToolsCall, "get_job_logs") + availableOn := filteredOn.AvailableTools(context.Background()) + if len(availableOn) != 1 { + t.Fatalf("Flag ON: Expected 1 tool, got %d", len(availableOn)) + } + if availableOn[0].FeatureFlagEnable != "consolidated_flag" { + t.Errorf("Flag ON: Expected tool with FeatureFlagEnable, got FeatureFlagEnable=%q, FeatureFlagDisable=%q", + availableOn[0].FeatureFlagEnable, availableOn[0].FeatureFlagDisable) + } +} + +// TestWithTools_DeprecatedAliasAndFeatureFlag tests that deprecated aliases work correctly +// when the old tool is controlled by a feature flag. This covers the scenario where: +// - Old tool "old_tool" has FeatureFlagDisable="my_flag" (available when flag is OFF) +// - New tool "new_tool" has FeatureFlagEnable="my_flag" (available when flag is ON) +// - Deprecated alias maps "old_tool" -> "new_tool" +// - User specifies --tools=old_tool +// Expected behavior: +// - Flag OFF: old_tool should be available (not the new_tool via alias) +// - Flag ON: new_tool should be available (via alias resolution) +func TestWithTools_DeprecatedAliasAndFeatureFlag(t *testing.T) { + oldTool := mockToolWithFlags("old_tool", "actions", true, "", "my_flag") + newTool := mockToolWithFlags("new_tool", "actions", true, "my_flag", "") + tools := []ServerTool{oldTool, newTool} + + deprecatedAliases := map[string]string{ + "old_tool": "new_tool", + } + + // Test 1: Flag OFF - old_tool should be available via direct name match + // (not via alias resolution to new_tool, since old_tool still exists) + regFlagOff := NewBuilder(). + SetTools(tools). + WithDeprecatedAliases(deprecatedAliases). + WithToolsets([]string{}). // No toolsets enabled + WithTools([]string{"old_tool"}). // Explicitly request old tool + Build() + availableOff := regFlagOff.AvailableTools(context.Background()) + if len(availableOff) != 1 { + t.Fatalf("Flag OFF: Expected 1 tool, got %d", len(availableOff)) + } + if availableOff[0].Tool.Name != "old_tool" { + t.Errorf("Flag OFF: Expected old_tool, got %s", availableOff[0].Tool.Name) + } + + // Test 2: Flag ON - new_tool should be available via alias resolution + checker := func(_ context.Context, flag string) (bool, error) { + return flag == "my_flag", nil + } + regFlagOn := NewBuilder(). + SetTools(tools). + WithDeprecatedAliases(deprecatedAliases). + WithToolsets([]string{}). // No toolsets enabled + WithTools([]string{"old_tool"}). // Request old tool name + WithFeatureChecker(checker). + Build() + availableOn := regFlagOn.AvailableTools(context.Background()) + if len(availableOn) != 1 { + t.Fatalf("Flag ON: Expected 1 tool, got %d", len(availableOn)) + } + if availableOn[0].Tool.Name != "new_tool" { + t.Errorf("Flag ON: Expected new_tool (via alias), got %s", availableOn[0].Tool.Name) + } +} diff --git a/pkg/inventory/server_tool.go b/pkg/inventory/server_tool.go index 362ee2643..095bedf2b 100644 --- a/pkg/inventory/server_tool.go +++ b/pkg/inventory/server_tool.go @@ -70,6 +70,15 @@ type ServerTool struct { // The context carries request-scoped information for the consumer to use. // Returns (enabled, error). On error, the tool should be treated as disabled. Enabled func(ctx context.Context) (bool, error) + + // RequiredScopes specifies the minimum OAuth scopes required for this tool. + // These are the scopes that must be present for the tool to function. + RequiredScopes []string + + // AcceptedScopes specifies all OAuth scopes that can be used with this tool. + // This includes the required scopes plus any higher-level scopes that provide + // the necessary permissions due to scope hierarchy. + AcceptedScopes []string } // IsReadOnly returns true if this tool is marked as read-only via annotations. diff --git a/pkg/raw/raw_mock.go b/pkg/raw/raw_mock.go deleted file mode 100644 index 30c7759d3..000000000 --- a/pkg/raw/raw_mock.go +++ /dev/null @@ -1,20 +0,0 @@ -package raw - -import "github.com/migueleliasweb/go-github-mock/src/mock" - -var GetRawReposContentsByOwnerByRepoByPath mock.EndpointPattern = mock.EndpointPattern{ - Pattern: "/{owner}/{repo}/HEAD/{path:.*}", - Method: "GET", -} -var GetRawReposContentsByOwnerByRepoByBranchByPath mock.EndpointPattern = mock.EndpointPattern{ - Pattern: "/{owner}/{repo}/refs/heads/{branch}/{path:.*}", - Method: "GET", -} -var GetRawReposContentsByOwnerByRepoByTagByPath mock.EndpointPattern = mock.EndpointPattern{ - Pattern: "/{owner}/{repo}/refs/tags/{tag}/{path:.*}", - Method: "GET", -} -var GetRawReposContentsByOwnerByRepoBySHAByPath mock.EndpointPattern = mock.EndpointPattern{ - Pattern: "/{owner}/{repo}/{sha}/{path:.*}", - Method: "GET", -} diff --git a/pkg/scopes/fetcher.go b/pkg/scopes/fetcher.go new file mode 100644 index 000000000..48e000179 --- /dev/null +++ b/pkg/scopes/fetcher.go @@ -0,0 +1,125 @@ +package scopes + +import ( + "context" + "fmt" + "net/http" + "net/url" + "strings" + "time" +) + +// OAuthScopesHeader is the HTTP response header containing the token's OAuth scopes. +const OAuthScopesHeader = "X-OAuth-Scopes" + +// DefaultFetchTimeout is the default timeout for scope fetching requests. +const DefaultFetchTimeout = 10 * time.Second + +// FetcherOptions configures the scope fetcher. +type FetcherOptions struct { + // HTTPClient is the HTTP client to use for requests. + // If nil, a default client with DefaultFetchTimeout is used. + HTTPClient *http.Client + + // APIHost is the GitHub API host (e.g., "https://api.github.com"). + // Defaults to "https://api.github.com" if empty. + APIHost string +} + +// Fetcher retrieves token scopes from GitHub's API. +// It uses an HTTP HEAD request to minimize bandwidth since we only need headers. +type Fetcher struct { + client *http.Client + apiHost string +} + +// NewFetcher creates a new scope fetcher with the given options. +func NewFetcher(opts FetcherOptions) *Fetcher { + client := opts.HTTPClient + if client == nil { + client = &http.Client{Timeout: DefaultFetchTimeout} + } + + apiHost := opts.APIHost + if apiHost == "" { + apiHost = "https://api.github.com" + } + + return &Fetcher{ + client: client, + apiHost: apiHost, + } +} + +// FetchTokenScopes retrieves the OAuth scopes for a token by making an HTTP HEAD +// request to the GitHub API and parsing the X-OAuth-Scopes header. +// +// Returns: +// - []string: List of scopes (empty if no scopes or fine-grained PAT) +// - error: Any HTTP or parsing error +// +// Note: Fine-grained PATs don't return the X-OAuth-Scopes header, so an empty +// slice is returned for those tokens. +func (f *Fetcher) FetchTokenScopes(ctx context.Context, token string) ([]string, error) { + // Use a lightweight endpoint that requires authentication + endpoint, err := url.JoinPath(f.apiHost, "/") + if err != nil { + return nil, fmt.Errorf("failed to construct API URL: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodHead, endpoint, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("X-GitHub-Api-Version", "2022-11-28") + + resp, err := f.client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to fetch scopes: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusUnauthorized { + return nil, fmt.Errorf("invalid or expired token") + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + return ParseScopeHeader(resp.Header.Get(OAuthScopesHeader)), nil +} + +// ParseScopeHeader parses the X-OAuth-Scopes header value into a list of scopes. +// The header contains comma-separated scope names. +// Returns an empty slice for empty or missing header. +func ParseScopeHeader(header string) []string { + if header == "" { + return []string{} + } + + parts := strings.Split(header, ",") + scopes := make([]string, 0, len(parts)) + for _, part := range parts { + scope := strings.TrimSpace(part) + if scope != "" { + scopes = append(scopes, scope) + } + } + return scopes +} + +// FetchTokenScopes is a convenience function that creates a default fetcher +// and fetches the token scopes. +func FetchTokenScopes(ctx context.Context, token string) ([]string, error) { + return NewFetcher(FetcherOptions{}).FetchTokenScopes(ctx, token) +} + +// FetchTokenScopesWithHost is a convenience function that creates a fetcher +// for a specific API host and fetches the token scopes. +func FetchTokenScopesWithHost(ctx context.Context, token, apiHost string) ([]string, error) { + return NewFetcher(FetcherOptions{APIHost: apiHost}).FetchTokenScopes(ctx, token) +} diff --git a/pkg/scopes/fetcher_test.go b/pkg/scopes/fetcher_test.go new file mode 100644 index 000000000..13feab5b0 --- /dev/null +++ b/pkg/scopes/fetcher_test.go @@ -0,0 +1,214 @@ +package scopes + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseScopeHeader(t *testing.T) { + tests := []struct { + name string + header string + expected []string + }{ + { + name: "empty header", + header: "", + expected: []string{}, + }, + { + name: "single scope", + header: "repo", + expected: []string{"repo"}, + }, + { + name: "multiple scopes", + header: "repo, user, gist", + expected: []string{"repo", "user", "gist"}, + }, + { + name: "scopes with extra whitespace", + header: " repo , user , gist ", + expected: []string{"repo", "user", "gist"}, + }, + { + name: "scopes without spaces", + header: "repo,user,gist", + expected: []string{"repo", "user", "gist"}, + }, + { + name: "scopes with colons", + header: "read:org, write:org, admin:org", + expected: []string{"read:org", "write:org", "admin:org"}, + }, + { + name: "empty parts are filtered", + header: "repo,,gist", + expected: []string{"repo", "gist"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ParseScopeHeader(tt.header) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestFetcher_FetchTokenScopes(t *testing.T) { + tests := []struct { + name string + handler http.HandlerFunc + expectedScopes []string + expectError bool + errorContains string + }{ + { + name: "successful fetch with multiple scopes", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("X-OAuth-Scopes", "repo, user, gist") + w.WriteHeader(http.StatusOK) + }, + expectedScopes: []string{"repo", "user", "gist"}, + expectError: false, + }, + { + name: "successful fetch with single scope", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("X-OAuth-Scopes", "repo") + w.WriteHeader(http.StatusOK) + }, + expectedScopes: []string{"repo"}, + expectError: false, + }, + { + name: "fine-grained PAT returns empty scopes", + handler: func(w http.ResponseWriter, _ *http.Request) { + // Fine-grained PATs don't return X-OAuth-Scopes + w.WriteHeader(http.StatusOK) + }, + expectedScopes: []string{}, + expectError: false, + }, + { + name: "unauthorized token", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + }, + expectError: true, + errorContains: "invalid or expired token", + }, + { + name: "server error", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + }, + expectError: true, + errorContains: "unexpected status code: 500", + }, + { + name: "verifies authorization header is set", + handler: func(w http.ResponseWriter, r *http.Request) { + authHeader := r.Header.Get("Authorization") + if authHeader != "Bearer test-token" { + w.WriteHeader(http.StatusUnauthorized) + return + } + w.Header().Set("X-OAuth-Scopes", "repo") + w.WriteHeader(http.StatusOK) + }, + expectedScopes: []string{"repo"}, + expectError: false, + }, + { + name: "verifies request method is HEAD", + handler: func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodHead { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + w.Header().Set("X-OAuth-Scopes", "repo") + w.WriteHeader(http.StatusOK) + }, + expectedScopes: []string{"repo"}, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(tt.handler) + defer server.Close() + + fetcher := NewFetcher(FetcherOptions{ + APIHost: server.URL, + }) + + scopes, err := fetcher.FetchTokenScopes(context.Background(), "test-token") + + if tt.expectError { + require.Error(t, err) + if tt.errorContains != "" { + assert.Contains(t, err.Error(), tt.errorContains) + } + } else { + require.NoError(t, err) + assert.Equal(t, tt.expectedScopes, scopes) + } + }) + } +} + +func TestFetcher_DefaultOptions(t *testing.T) { + fetcher := NewFetcher(FetcherOptions{}) + + // Verify default API host is set + assert.Equal(t, "https://api.github.com", fetcher.apiHost) + + // Verify default HTTP client is set with timeout + assert.NotNil(t, fetcher.client) + assert.Equal(t, DefaultFetchTimeout, fetcher.client.Timeout) +} + +func TestFetcher_CustomHTTPClient(t *testing.T) { + customClient := &http.Client{Timeout: 5 * time.Second} + + fetcher := NewFetcher(FetcherOptions{ + HTTPClient: customClient, + }) + + assert.Equal(t, customClient, fetcher.client) +} + +func TestFetcher_CustomAPIHost(t *testing.T) { + fetcher := NewFetcher(FetcherOptions{ + APIHost: "https://api.github.enterprise.com", + }) + + assert.Equal(t, "https://api.github.enterprise.com", fetcher.apiHost) +} + +func TestFetcher_ContextCancellation(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + time.Sleep(100 * time.Millisecond) + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + fetcher := NewFetcher(FetcherOptions{ + APIHost: server.URL, + }) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // Cancel immediately + + _, err := fetcher.FetchTokenScopes(ctx, "test-token") + require.Error(t, err) +} diff --git a/pkg/scopes/scopes.go b/pkg/scopes/scopes.go new file mode 100644 index 000000000..a9b06e988 --- /dev/null +++ b/pkg/scopes/scopes.go @@ -0,0 +1,194 @@ +package scopes + +import "sort" + +// Scope represents a GitHub OAuth scope. +// These constants define all OAuth scopes used by the GitHub MCP server tools. +// See https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/scopes-for-oauth-apps +type Scope string + +const ( + // NoScope indicates no scope is required (public access). + NoScope Scope = "" + + // Repo grants full control of private repositories + Repo Scope = "repo" + + // PublicRepo grants access to public repositories + PublicRepo Scope = "public_repo" + + // ReadOrg grants read-only access to organization membership, teams, and projects + ReadOrg Scope = "read:org" + + // WriteOrg grants write access to organization membership and teams + WriteOrg Scope = "write:org" + + // AdminOrg grants full control of organizations and teams + AdminOrg Scope = "admin:org" + + // Gist grants write access to gists + Gist Scope = "gist" + + // Notifications grants access to notifications + Notifications Scope = "notifications" + + // ReadProject grants read-only access to projects + ReadProject Scope = "read:project" + + // Project grants full control of projects + Project Scope = "project" + + // SecurityEvents grants read and write access to security events + SecurityEvents Scope = "security_events" + + // User grants read/write access to profile info + User Scope = "user" + + // ReadUser grants read-only access to profile info + ReadUser Scope = "read:user" + + // UserEmail grants read access to user email addresses + UserEmail Scope = "user:email" + + // ReadPackages grants read access to packages + ReadPackages Scope = "read:packages" + + // WritePackages grants write access to packages + WritePackages Scope = "write:packages" +) + +// ScopeHierarchy defines parent-child relationships between scopes. +// A parent scope implicitly grants access to all child scopes. +// For example, "repo" grants access to "public_repo" and "security_events". +var ScopeHierarchy = map[Scope][]Scope{ + Repo: {PublicRepo, SecurityEvents}, + AdminOrg: {WriteOrg, ReadOrg}, + WriteOrg: {ReadOrg}, + Project: {ReadProject}, + WritePackages: {ReadPackages}, + User: {ReadUser, UserEmail}, +} + +// ScopeSet represents a set of OAuth scopes. +type ScopeSet map[Scope]bool + +// NewScopeSet creates a new ScopeSet from the given scopes. +func NewScopeSet(scopes ...Scope) ScopeSet { + set := make(ScopeSet) + for _, scope := range scopes { + set[scope] = true + } + return set +} + +// ToSlice converts a ScopeSet to a slice of Scope values. +func (s ScopeSet) ToSlice() []Scope { + scopes := make([]Scope, 0, len(s)) + for scope := range s { + scopes = append(scopes, scope) + } + // Sort for deterministic output + sort.Slice(scopes, func(i, j int) bool { + return scopes[i] < scopes[j] + }) + return scopes +} + +// ToStringSlice converts a ScopeSet to a slice of string values. +// The returned slice is sorted for deterministic output. +func (s ScopeSet) ToStringSlice() []string { + scopes := make([]string, 0, len(s)) + for scope := range s { + scopes = append(scopes, string(scope)) + } + sort.Strings(scopes) + return scopes +} + +// ToStringSlice converts a slice of Scopes to a slice of strings. +func ToStringSlice(scopes ...Scope) []string { + result := make([]string, len(scopes)) + for i, scope := range scopes { + result[i] = string(scope) + } + return result +} + +// ExpandScopes takes a list of required scopes and returns all accepted scopes +// including parent scopes from the hierarchy. +// For example, if "public_repo" is required, "repo" is also accepted since +// having the "repo" scope grants access to "public_repo". +// The returned slice is sorted for deterministic output. +func ExpandScopes(required ...Scope) []string { + if len(required) == 0 { + return nil + } + + accepted := make(map[string]bool) + + // Add required scopes + for _, scope := range required { + accepted[string(scope)] = true + } + + // Add parent scopes that grant access to required scopes + for parent, children := range ScopeHierarchy { + for _, child := range children { + if accepted[string(child)] { + accepted[string(parent)] = true + } + } + } + + // Convert to slice and sort for deterministic output + result := make([]string, 0, len(accepted)) + for scope := range accepted { + result = append(result, scope) + } + sort.Strings(result) + return result +} + +// expandScopeSet returns a set of all scopes granted by the given scopes, +// including child scopes from the hierarchy. +// For example, if "repo" is provided, the result includes "repo", "public_repo", +// and "security_events" since "repo" grants access to those child scopes. +func expandScopeSet(scopes []string) map[string]bool { + expanded := make(map[string]bool, len(scopes)) + for _, scope := range scopes { + expanded[scope] = true + // Add child scopes granted by this scope + if children, ok := ScopeHierarchy[Scope(scope)]; ok { + for _, child := range children { + expanded[string(child)] = true + } + } + } + return expanded +} + +// HasRequiredScopes checks if tokenScopes satisfy the acceptedScopes requirement. +// A tool's acceptedScopes includes both the required scopes AND parent scopes +// that implicitly grant the required permissions (via ExpandScopes). +// +// For PAT filtering: if ANY of the acceptedScopes are granted by the token +// (directly or via scope hierarchy), the tool should be visible. +// +// Returns true if the tool should be visible to the token holder. +func HasRequiredScopes(tokenScopes []string, acceptedScopes []string) bool { + // No scopes required = always allowed + if len(acceptedScopes) == 0 { + return true + } + + // Expand token scopes to include child scopes they grant + grantedScopes := expandScopeSet(tokenScopes) + + // Check if any accepted scope is granted by the token + for _, accepted := range acceptedScopes { + if grantedScopes[accepted] { + return true + } + } + return false +} diff --git a/pkg/scopes/scopes_test.go b/pkg/scopes/scopes_test.go new file mode 100644 index 000000000..b8e0d8e42 --- /dev/null +++ b/pkg/scopes/scopes_test.go @@ -0,0 +1,332 @@ +package scopes + +import ( + "sort" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestExpandScopes(t *testing.T) { + tests := []struct { + name string + required []Scope + expected []string + }{ + { + name: "nil returns nil", + required: nil, + expected: nil, + }, + { + name: "empty returns nil", + required: []Scope{}, + expected: nil, + }, + { + name: "repo scope returns just repo", + required: []Scope{Repo}, + expected: []string{"repo"}, + }, + { + name: "public_repo also accepts repo (parent)", + required: []Scope{PublicRepo}, + expected: []string{"public_repo", "repo"}, + }, + { + name: "security_events also accepts repo (parent)", + required: []Scope{SecurityEvents}, + expected: []string{"repo", "security_events"}, + }, + { + name: "read:org also accepts write:org and admin:org (parents)", + required: []Scope{ReadOrg}, + expected: []string{"admin:org", "read:org", "write:org"}, + }, + { + name: "write:org also accepts admin:org (parent)", + required: []Scope{WriteOrg}, + expected: []string{"admin:org", "write:org"}, + }, + { + name: "admin:org returns just admin:org (no parent)", + required: []Scope{AdminOrg}, + expected: []string{"admin:org"}, + }, + { + name: "read:project also accepts project (parent)", + required: []Scope{ReadProject}, + expected: []string{"project", "read:project"}, + }, + { + name: "project returns just project (no parent)", + required: []Scope{Project}, + expected: []string{"project"}, + }, + { + name: "gist returns just gist (no parent)", + required: []Scope{Gist}, + expected: []string{"gist"}, + }, + { + name: "notifications returns just notifications (no parent)", + required: []Scope{Notifications}, + expected: []string{"notifications"}, + }, + { + name: "read:packages also accepts write:packages (parent)", + required: []Scope{ReadPackages}, + expected: []string{"read:packages", "write:packages"}, + }, + { + name: "read:user also accepts user (parent)", + required: []Scope{ReadUser}, + expected: []string{"read:user", "user"}, + }, + { + name: "multiple scopes combine correctly", + required: []Scope{PublicRepo, ReadOrg}, + expected: []string{"admin:org", "public_repo", "read:org", "repo", "write:org"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ExpandScopes(tt.required...) + + // Sort both for consistent comparison + if result != nil { + sort.Strings(result) + } + if tt.expected != nil { + sort.Strings(tt.expected) + } + + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestToStringSlice(t *testing.T) { + tests := []struct { + name string + scopes []Scope + expected []string + }{ + { + name: "empty returns empty", + scopes: []Scope{}, + expected: []string{}, + }, + { + name: "single scope", + scopes: []Scope{Repo}, + expected: []string{"repo"}, + }, + { + name: "multiple scopes", + scopes: []Scope{Repo, Gist, ReadOrg}, + expected: []string{"repo", "gist", "read:org"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ToStringSlice(tt.scopes...) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestScopeHierarchy(t *testing.T) { + // Verify the hierarchy is correctly defined + assert.Contains(t, ScopeHierarchy[Repo], PublicRepo) + assert.Contains(t, ScopeHierarchy[Repo], SecurityEvents) + assert.Contains(t, ScopeHierarchy[AdminOrg], WriteOrg) + assert.Contains(t, ScopeHierarchy[AdminOrg], ReadOrg) + assert.Contains(t, ScopeHierarchy[WriteOrg], ReadOrg) + assert.Contains(t, ScopeHierarchy[Project], ReadProject) + assert.Contains(t, ScopeHierarchy[WritePackages], ReadPackages) + assert.Contains(t, ScopeHierarchy[User], ReadUser) + assert.Contains(t, ScopeHierarchy[User], UserEmail) +} + +func TestExpandScopeSet(t *testing.T) { + tests := []struct { + name string + scopes []string + expected map[string]bool + }{ + { + name: "empty scopes", + scopes: []string{}, + expected: map[string]bool{}, + }, + { + name: "repo expands to include public_repo and security_events", + scopes: []string{"repo"}, + expected: map[string]bool{ + "repo": true, + "public_repo": true, + "security_events": true, + }, + }, + { + name: "admin:org expands to include write:org and read:org", + scopes: []string{"admin:org"}, + expected: map[string]bool{ + "admin:org": true, + "write:org": true, + "read:org": true, + }, + }, + { + name: "write:org expands to include read:org", + scopes: []string{"write:org"}, + expected: map[string]bool{ + "write:org": true, + "read:org": true, + }, + }, + { + name: "user expands to include read:user and user:email", + scopes: []string{"user"}, + expected: map[string]bool{ + "user": true, + "read:user": true, + "user:email": true, + }, + }, + { + name: "scope without children stays as-is", + scopes: []string{"gist"}, + expected: map[string]bool{ + "gist": true, + }, + }, + { + name: "multiple scopes combine correctly", + scopes: []string{"repo", "gist"}, + expected: map[string]bool{ + "repo": true, + "public_repo": true, + "security_events": true, + "gist": true, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := expandScopeSet(tt.scopes) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestHasRequiredScopes(t *testing.T) { + tests := []struct { + name string + tokenScopes []string + acceptedScopes []string + expected bool + }{ + { + name: "no accepted scopes - always allowed", + tokenScopes: []string{}, + acceptedScopes: []string{}, + expected: true, + }, + { + name: "nil accepted scopes - always allowed", + tokenScopes: []string{"repo"}, + acceptedScopes: nil, + expected: true, + }, + { + name: "token has exact required scope", + tokenScopes: []string{"repo"}, + acceptedScopes: []string{"repo"}, + expected: true, + }, + { + name: "token has parent scope that grants access", + tokenScopes: []string{"repo"}, + acceptedScopes: []string{"public_repo"}, + expected: true, + }, + { + name: "token has parent scope for security_events", + tokenScopes: []string{"repo"}, + acceptedScopes: []string{"security_events"}, + expected: true, + }, + { + name: "token has admin:org which grants read:org", + tokenScopes: []string{"admin:org"}, + acceptedScopes: []string{"read:org"}, + expected: true, + }, + { + name: "token has write:org which grants read:org", + tokenScopes: []string{"write:org"}, + acceptedScopes: []string{"read:org"}, + expected: true, + }, + { + name: "token missing required scope", + tokenScopes: []string{"gist"}, + acceptedScopes: []string{"repo"}, + expected: false, + }, + { + name: "token has child but not parent - fails", + tokenScopes: []string{"public_repo"}, + acceptedScopes: []string{"repo"}, + expected: false, + }, + { + name: "multiple token scopes - one matches", + tokenScopes: []string{"gist", "repo"}, + acceptedScopes: []string{"public_repo"}, + expected: true, + }, + { + name: "multiple accepted scopes - token has one", + tokenScopes: []string{"repo"}, + acceptedScopes: []string{"repo", "admin:org"}, + expected: true, + }, + { + name: "empty token scopes - fails when scopes required", + tokenScopes: []string{}, + acceptedScopes: []string{"repo"}, + expected: false, + }, + { + name: "user scope grants read:user", + tokenScopes: []string{"user"}, + acceptedScopes: []string{"read:user"}, + expected: true, + }, + { + name: "user scope grants user:email", + tokenScopes: []string{"user"}, + acceptedScopes: []string{"user:email"}, + expected: true, + }, + { + name: "write:packages grants read:packages", + tokenScopes: []string{"write:packages"}, + acceptedScopes: []string{"read:packages"}, + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := HasRequiredScopes(tt.tokenScopes, tt.acceptedScopes) + assert.Equal(t, tt.expected, result) + }) + } +} diff --git a/script/list-scopes b/script/list-scopes new file mode 100755 index 000000000..2f7502823 --- /dev/null +++ b/script/list-scopes @@ -0,0 +1,24 @@ +#!/bin/bash +# +# List required OAuth scopes for enabled tools. +# +# Usage: +# script/list-scopes [--toolsets=...] [--output=text|json|summary] +# +# Examples: +# script/list-scopes +# script/list-scopes --toolsets=all --output=json +# script/list-scopes --toolsets=repos,issues --output=summary +# + +set -e + +cd "$(dirname "$0")/.." + +# Build the server if it doesn't exist or is outdated +if [ ! -f github-mcp-server ] || [ cmd/github-mcp-server/list_scopes.go -nt github-mcp-server ]; then + echo "Building github-mcp-server..." >&2 + go build -o github-mcp-server ./cmd/github-mcp-server +fi + +exec ./github-mcp-server list-scopes "$@" diff --git a/server.json b/server.json index 83b4e06be..15fdf47bd 100644 --- a/server.json +++ b/server.json @@ -8,6 +8,31 @@ "source": "github" }, "version": "${VERSION}", + "packages": [ + { + "registryType": "oci", + "identifier": "ghcr.io/github/github-mcp-server:${VERSION}", + "transport": { + "type": "stdio" + }, + "runtimeArguments": [ + { + "type": "named", + "name": "-e", + "description": "Set an environment variable in the runtime", + "value": "GITHUB_PERSONAL_ACCESS_TOKEN={token}", + "isRequired": true, + "variables": { + "token": { + "isRequired": true, + "isSecret": true, + "format": "string" + } + } + } + ] + } + ], "remotes": [ { "type": "streamable-http", @@ -15,8 +40,7 @@ "headers": [ { "name": "Authorization", - "description": "Authentication token (PAT or App token)", - "isRequired": true, + "description": "Authorization header with authentication token (PAT or App token)", "isSecret": true } ] diff --git a/third-party-licenses.darwin.md b/third-party-licenses.darwin.md index 5cb31cac4..fb4392fb9 100644 --- a/third-party-licenses.darwin.md +++ b/third-party-licenses.darwin.md @@ -18,17 +18,14 @@ The following packages are included for the amd64, arm64 architectures. - [github.com/go-openapi/jsonpointer](https://pkg.go.dev/github.com/go-openapi/jsonpointer) ([Apache-2.0](https://github.com/go-openapi/jsonpointer/blob/v0.19.5/LICENSE)) - [github.com/go-openapi/swag](https://pkg.go.dev/github.com/go-openapi/swag) ([Apache-2.0](https://github.com/go-openapi/swag/blob/v0.21.1/LICENSE)) - [github.com/go-viper/mapstructure/v2](https://pkg.go.dev/github.com/go-viper/mapstructure/v2) ([MIT](https://github.com/go-viper/mapstructure/blob/v2.4.0/LICENSE)) - - [github.com/google/go-github/v71/github](https://pkg.go.dev/github.com/google/go-github/v71/github) ([BSD-3-Clause](https://github.com/google/go-github/blob/v71.0.0/LICENSE)) - [github.com/google/go-github/v79/github](https://pkg.go.dev/github.com/google/go-github/v79/github) ([BSD-3-Clause](https://github.com/google/go-github/blob/v79.0.0/LICENSE)) - [github.com/google/go-querystring/query](https://pkg.go.dev/github.com/google/go-querystring/query) ([BSD-3-Clause](https://github.com/google/go-querystring/blob/v1.1.0/LICENSE)) - [github.com/google/jsonschema-go/jsonschema](https://pkg.go.dev/github.com/google/jsonschema-go/jsonschema) ([MIT](https://github.com/google/jsonschema-go/blob/v0.4.2/LICENSE)) - [github.com/gorilla/css/scanner](https://pkg.go.dev/github.com/gorilla/css/scanner) ([BSD-3-Clause](https://github.com/gorilla/css/blob/v1.0.1/LICENSE)) - - [github.com/gorilla/mux](https://pkg.go.dev/github.com/gorilla/mux) ([BSD-3-Clause](https://github.com/gorilla/mux/blob/v1.8.0/LICENSE)) - [github.com/josephburnett/jd/v2](https://pkg.go.dev/github.com/josephburnett/jd/v2) ([MIT](https://github.com/josephburnett/jd/blob/v1.9.2/LICENSE)) - [github.com/josharian/intern](https://pkg.go.dev/github.com/josharian/intern) ([MIT](https://github.com/josharian/intern/blob/v1.0.0/license.md)) - [github.com/mailru/easyjson](https://pkg.go.dev/github.com/mailru/easyjson) ([MIT](https://github.com/mailru/easyjson/blob/v0.7.7/LICENSE)) - [github.com/microcosm-cc/bluemonday](https://pkg.go.dev/github.com/microcosm-cc/bluemonday) ([BSD-3-Clause](https://github.com/microcosm-cc/bluemonday/blob/v1.0.27/LICENSE.md)) - - [github.com/migueleliasweb/go-github-mock/src/mock](https://pkg.go.dev/github.com/migueleliasweb/go-github-mock/src/mock) ([MIT](https://github.com/migueleliasweb/go-github-mock/blob/v1.3.0/LICENSE)) - [github.com/modelcontextprotocol/go-sdk](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk) ([MIT](https://github.com/modelcontextprotocol/go-sdk/blob/v1.2.0/LICENSE)) - [github.com/muesli/cache2go](https://pkg.go.dev/github.com/muesli/cache2go) ([BSD-3-Clause](https://github.com/muesli/cache2go/blob/518229cd8021/LICENSE.txt)) - [github.com/pelletier/go-toml/v2](https://pkg.go.dev/github.com/pelletier/go-toml/v2) ([MIT](https://github.com/pelletier/go-toml/blob/v2.2.4/LICENSE)) @@ -49,7 +46,6 @@ The following packages are included for the amd64, arm64 architectures. - [golang.org/x/net/html](https://pkg.go.dev/golang.org/x/net/html) ([BSD-3-Clause](https://cs.opensource.google/go/x/net/+/v0.38.0:LICENSE)) - [golang.org/x/sys/unix](https://pkg.go.dev/golang.org/x/sys/unix) ([BSD-3-Clause](https://cs.opensource.google/go/x/sys/+/v0.31.0:LICENSE)) - [golang.org/x/text](https://pkg.go.dev/golang.org/x/text) ([BSD-3-Clause](https://cs.opensource.google/go/x/text/+/v0.28.0:LICENSE)) - - [golang.org/x/time/rate](https://pkg.go.dev/golang.org/x/time/rate) ([BSD-3-Clause](https://cs.opensource.google/go/x/time/+/v0.5.0:LICENSE)) - [gopkg.in/yaml.v2](https://pkg.go.dev/gopkg.in/yaml.v2) ([Apache-2.0](https://github.com/go-yaml/yaml/blob/v2.4.0/LICENSE)) [github/github-mcp-server]: https://github.com/github/github-mcp-server diff --git a/third-party-licenses.linux.md b/third-party-licenses.linux.md index 8d0829a63..564f20dcb 100644 --- a/third-party-licenses.linux.md +++ b/third-party-licenses.linux.md @@ -18,17 +18,14 @@ The following packages are included for the 386, amd64, arm64 architectures. - [github.com/go-openapi/jsonpointer](https://pkg.go.dev/github.com/go-openapi/jsonpointer) ([Apache-2.0](https://github.com/go-openapi/jsonpointer/blob/v0.19.5/LICENSE)) - [github.com/go-openapi/swag](https://pkg.go.dev/github.com/go-openapi/swag) ([Apache-2.0](https://github.com/go-openapi/swag/blob/v0.21.1/LICENSE)) - [github.com/go-viper/mapstructure/v2](https://pkg.go.dev/github.com/go-viper/mapstructure/v2) ([MIT](https://github.com/go-viper/mapstructure/blob/v2.4.0/LICENSE)) - - [github.com/google/go-github/v71/github](https://pkg.go.dev/github.com/google/go-github/v71/github) ([BSD-3-Clause](https://github.com/google/go-github/blob/v71.0.0/LICENSE)) - [github.com/google/go-github/v79/github](https://pkg.go.dev/github.com/google/go-github/v79/github) ([BSD-3-Clause](https://github.com/google/go-github/blob/v79.0.0/LICENSE)) - [github.com/google/go-querystring/query](https://pkg.go.dev/github.com/google/go-querystring/query) ([BSD-3-Clause](https://github.com/google/go-querystring/blob/v1.1.0/LICENSE)) - [github.com/google/jsonschema-go/jsonschema](https://pkg.go.dev/github.com/google/jsonschema-go/jsonschema) ([MIT](https://github.com/google/jsonschema-go/blob/v0.4.2/LICENSE)) - [github.com/gorilla/css/scanner](https://pkg.go.dev/github.com/gorilla/css/scanner) ([BSD-3-Clause](https://github.com/gorilla/css/blob/v1.0.1/LICENSE)) - - [github.com/gorilla/mux](https://pkg.go.dev/github.com/gorilla/mux) ([BSD-3-Clause](https://github.com/gorilla/mux/blob/v1.8.0/LICENSE)) - [github.com/josephburnett/jd/v2](https://pkg.go.dev/github.com/josephburnett/jd/v2) ([MIT](https://github.com/josephburnett/jd/blob/v1.9.2/LICENSE)) - [github.com/josharian/intern](https://pkg.go.dev/github.com/josharian/intern) ([MIT](https://github.com/josharian/intern/blob/v1.0.0/license.md)) - [github.com/mailru/easyjson](https://pkg.go.dev/github.com/mailru/easyjson) ([MIT](https://github.com/mailru/easyjson/blob/v0.7.7/LICENSE)) - [github.com/microcosm-cc/bluemonday](https://pkg.go.dev/github.com/microcosm-cc/bluemonday) ([BSD-3-Clause](https://github.com/microcosm-cc/bluemonday/blob/v1.0.27/LICENSE.md)) - - [github.com/migueleliasweb/go-github-mock/src/mock](https://pkg.go.dev/github.com/migueleliasweb/go-github-mock/src/mock) ([MIT](https://github.com/migueleliasweb/go-github-mock/blob/v1.3.0/LICENSE)) - [github.com/modelcontextprotocol/go-sdk](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk) ([MIT](https://github.com/modelcontextprotocol/go-sdk/blob/v1.2.0/LICENSE)) - [github.com/muesli/cache2go](https://pkg.go.dev/github.com/muesli/cache2go) ([BSD-3-Clause](https://github.com/muesli/cache2go/blob/518229cd8021/LICENSE.txt)) - [github.com/pelletier/go-toml/v2](https://pkg.go.dev/github.com/pelletier/go-toml/v2) ([MIT](https://github.com/pelletier/go-toml/blob/v2.2.4/LICENSE)) @@ -49,7 +46,6 @@ The following packages are included for the 386, amd64, arm64 architectures. - [golang.org/x/net/html](https://pkg.go.dev/golang.org/x/net/html) ([BSD-3-Clause](https://cs.opensource.google/go/x/net/+/v0.38.0:LICENSE)) - [golang.org/x/sys/unix](https://pkg.go.dev/golang.org/x/sys/unix) ([BSD-3-Clause](https://cs.opensource.google/go/x/sys/+/v0.31.0:LICENSE)) - [golang.org/x/text](https://pkg.go.dev/golang.org/x/text) ([BSD-3-Clause](https://cs.opensource.google/go/x/text/+/v0.28.0:LICENSE)) - - [golang.org/x/time/rate](https://pkg.go.dev/golang.org/x/time/rate) ([BSD-3-Clause](https://cs.opensource.google/go/x/time/+/v0.5.0:LICENSE)) - [gopkg.in/yaml.v2](https://pkg.go.dev/gopkg.in/yaml.v2) ([Apache-2.0](https://github.com/go-yaml/yaml/blob/v2.4.0/LICENSE)) [github/github-mcp-server]: https://github.com/github/github-mcp-server diff --git a/third-party-licenses.windows.md b/third-party-licenses.windows.md index 79b925138..6b4dcfb97 100644 --- a/third-party-licenses.windows.md +++ b/third-party-licenses.windows.md @@ -18,18 +18,15 @@ The following packages are included for the 386, amd64, arm64 architectures. - [github.com/go-openapi/jsonpointer](https://pkg.go.dev/github.com/go-openapi/jsonpointer) ([Apache-2.0](https://github.com/go-openapi/jsonpointer/blob/v0.19.5/LICENSE)) - [github.com/go-openapi/swag](https://pkg.go.dev/github.com/go-openapi/swag) ([Apache-2.0](https://github.com/go-openapi/swag/blob/v0.21.1/LICENSE)) - [github.com/go-viper/mapstructure/v2](https://pkg.go.dev/github.com/go-viper/mapstructure/v2) ([MIT](https://github.com/go-viper/mapstructure/blob/v2.4.0/LICENSE)) - - [github.com/google/go-github/v71/github](https://pkg.go.dev/github.com/google/go-github/v71/github) ([BSD-3-Clause](https://github.com/google/go-github/blob/v71.0.0/LICENSE)) - [github.com/google/go-github/v79/github](https://pkg.go.dev/github.com/google/go-github/v79/github) ([BSD-3-Clause](https://github.com/google/go-github/blob/v79.0.0/LICENSE)) - [github.com/google/go-querystring/query](https://pkg.go.dev/github.com/google/go-querystring/query) ([BSD-3-Clause](https://github.com/google/go-querystring/blob/v1.1.0/LICENSE)) - [github.com/google/jsonschema-go/jsonschema](https://pkg.go.dev/github.com/google/jsonschema-go/jsonschema) ([MIT](https://github.com/google/jsonschema-go/blob/v0.4.2/LICENSE)) - [github.com/gorilla/css/scanner](https://pkg.go.dev/github.com/gorilla/css/scanner) ([BSD-3-Clause](https://github.com/gorilla/css/blob/v1.0.1/LICENSE)) - - [github.com/gorilla/mux](https://pkg.go.dev/github.com/gorilla/mux) ([BSD-3-Clause](https://github.com/gorilla/mux/blob/v1.8.0/LICENSE)) - [github.com/inconshreveable/mousetrap](https://pkg.go.dev/github.com/inconshreveable/mousetrap) ([Apache-2.0](https://github.com/inconshreveable/mousetrap/blob/v1.1.0/LICENSE)) - [github.com/josephburnett/jd/v2](https://pkg.go.dev/github.com/josephburnett/jd/v2) ([MIT](https://github.com/josephburnett/jd/blob/v1.9.2/LICENSE)) - [github.com/josharian/intern](https://pkg.go.dev/github.com/josharian/intern) ([MIT](https://github.com/josharian/intern/blob/v1.0.0/license.md)) - [github.com/mailru/easyjson](https://pkg.go.dev/github.com/mailru/easyjson) ([MIT](https://github.com/mailru/easyjson/blob/v0.7.7/LICENSE)) - [github.com/microcosm-cc/bluemonday](https://pkg.go.dev/github.com/microcosm-cc/bluemonday) ([BSD-3-Clause](https://github.com/microcosm-cc/bluemonday/blob/v1.0.27/LICENSE.md)) - - [github.com/migueleliasweb/go-github-mock/src/mock](https://pkg.go.dev/github.com/migueleliasweb/go-github-mock/src/mock) ([MIT](https://github.com/migueleliasweb/go-github-mock/blob/v1.3.0/LICENSE)) - [github.com/modelcontextprotocol/go-sdk](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk) ([MIT](https://github.com/modelcontextprotocol/go-sdk/blob/v1.2.0/LICENSE)) - [github.com/muesli/cache2go](https://pkg.go.dev/github.com/muesli/cache2go) ([BSD-3-Clause](https://github.com/muesli/cache2go/blob/518229cd8021/LICENSE.txt)) - [github.com/pelletier/go-toml/v2](https://pkg.go.dev/github.com/pelletier/go-toml/v2) ([MIT](https://github.com/pelletier/go-toml/blob/v2.2.4/LICENSE)) @@ -50,7 +47,6 @@ The following packages are included for the 386, amd64, arm64 architectures. - [golang.org/x/net/html](https://pkg.go.dev/golang.org/x/net/html) ([BSD-3-Clause](https://cs.opensource.google/go/x/net/+/v0.38.0:LICENSE)) - [golang.org/x/sys/windows](https://pkg.go.dev/golang.org/x/sys/windows) ([BSD-3-Clause](https://cs.opensource.google/go/x/sys/+/v0.31.0:LICENSE)) - [golang.org/x/text](https://pkg.go.dev/golang.org/x/text) ([BSD-3-Clause](https://cs.opensource.google/go/x/text/+/v0.28.0:LICENSE)) - - [golang.org/x/time/rate](https://pkg.go.dev/golang.org/x/time/rate) ([BSD-3-Clause](https://cs.opensource.google/go/x/time/+/v0.5.0:LICENSE)) - [gopkg.in/yaml.v2](https://pkg.go.dev/gopkg.in/yaml.v2) ([Apache-2.0](https://github.com/go-yaml/yaml/blob/v2.4.0/LICENSE)) [github/github-mcp-server]: https://github.com/github/github-mcp-server diff --git a/third-party/github.com/google/go-github/v71/github/LICENSE b/third-party/github.com/google/go-github/v71/github/LICENSE deleted file mode 100644 index 28b6486f0..000000000 --- a/third-party/github.com/google/go-github/v71/github/LICENSE +++ /dev/null @@ -1,27 +0,0 @@ -Copyright (c) 2013 The go-github AUTHORS. All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are -met: - - * Redistributions of source code must retain the above copyright -notice, this list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above -copyright notice, this list of conditions and the following disclaimer -in the documentation and/or other materials provided with the -distribution. - * Neither the name of Google Inc. nor the names of its -contributors may be used to endorse or promote products derived from -this software without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR -A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT -OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, -SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT -LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, -DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY -THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third-party/github.com/gorilla/mux/LICENSE b/third-party/github.com/gorilla/mux/LICENSE deleted file mode 100644 index 6903df638..000000000 --- a/third-party/github.com/gorilla/mux/LICENSE +++ /dev/null @@ -1,27 +0,0 @@ -Copyright (c) 2012-2018 The Gorilla Authors. All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are -met: - - * Redistributions of source code must retain the above copyright -notice, this list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above -copyright notice, this list of conditions and the following disclaimer -in the documentation and/or other materials provided with the -distribution. - * Neither the name of Google Inc. nor the names of its -contributors may be used to endorse or promote products derived from -this software without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR -A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT -OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, -SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT -LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, -DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY -THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third-party/github.com/migueleliasweb/go-github-mock/src/mock/LICENSE b/third-party/github.com/migueleliasweb/go-github-mock/src/mock/LICENSE deleted file mode 100644 index 86d42717d..000000000 --- a/third-party/github.com/migueleliasweb/go-github-mock/src/mock/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2021 Miguel Elias dos Santos - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/third-party/golang.org/x/time/rate/LICENSE b/third-party/golang.org/x/time/rate/LICENSE deleted file mode 100644 index 6a66aea5e..000000000 --- a/third-party/golang.org/x/time/rate/LICENSE +++ /dev/null @@ -1,27 +0,0 @@ -Copyright (c) 2009 The Go Authors. All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are -met: - - * Redistributions of source code must retain the above copyright -notice, this list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above -copyright notice, this list of conditions and the following disclaimer -in the documentation and/or other materials provided with the -distribution. - * Neither the name of Google Inc. nor the names of its -contributors may be used to endorse or promote products derived from -this software without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR -A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT -OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, -SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT -LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, -DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY -THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.