diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
index cd0b3f1dd..e35f807d5 100644
--- a/.github/pull_request_template.md
+++ b/.github/pull_request_template.md
@@ -33,6 +33,13 @@ Fixes #
- [ ] Auth / permissions considered
- [ ] Data exposure, filtering, or token/size limits considered
+## Tool renaming
+- [ ] I am renaming tools as part of this PR (e.g. a part of a consolidation effort)
+ - [ ] I have added the new tool aliases in `deprecated_tool_aliases.go`
+- [ ] I am not renaming tools as part of this PR
+
+Note: if you're renaming tools, you *must* add the tool aliases. For more information on how to do so, please refer to the [official docs](https://github.com/github/github-mcp-server/blob/main/docs/tool-renaming.md).
+
## Lint & tests
- [ ] Linted locally with `./script/lint`
diff --git a/.github/workflows/ai-issue-assessment.yml b/.github/workflows/ai-issue-assessment.yml
index 7481ce6db..189ae959f 100644
--- a/.github/workflows/ai-issue-assessment.yml
+++ b/.github/workflows/ai-issue-assessment.yml
@@ -6,9 +6,6 @@ on:
jobs:
ai-issue-assessment:
- if: >
- (github.event.action == 'opened' && github.event.issue.labels[0] == null) ||
- (github.event.action == 'labeled' && github.event.label.name == 'bug')
runs-on: ubuntu-latest
permissions:
issues: write
@@ -23,8 +20,8 @@ jobs:
uses: github/ai-assessment-comment-labeler@e3bedc38cfffa9179fe4cee8f7ecc93bffb3fee7 # v1.0.1
with:
token: ${{ secrets.GITHUB_TOKEN }}
- ai_review_label: 'bug, enhancement'
+ ai_review_label: "request ai review"
issue_number: ${{ github.event.issue.number }}
issue_body: ${{ github.event.issue.body }}
- prompts_directory: '.github/prompts'
- labels_to_prompts_mapping: 'bug,bug-report-review.prompt.yml|default,default-issue-review.prompt.yml'
+ prompts_directory: ".github/prompts"
+ labels_to_prompts_mapping: "bug,bug-report-review.prompt.yml|default,default-issue-review.prompt.yml"
diff --git a/.github/workflows/code-scanning.yml b/.github/workflows/code-scanning.yml
index 7dda8c9bd..02c19fc77 100644
--- a/.github/workflows/code-scanning.yml
+++ b/.github/workflows/code-scanning.yml
@@ -14,6 +14,8 @@ env:
jobs:
analyze:
name: Analyze (${{ matrix.language }})
+ # Only run on the main repository, not on forks
+ if: github.repository == 'github/github-mcp-server'
runs-on: ${{ fromJSON(matrix.runner) }}
permissions:
actions: read
@@ -46,6 +48,9 @@ jobs:
queries: "" # Default query suite
packs: github/ccr-${{ matrix.language }}-queries
config: |
+ paths-ignore:
+ - third-party
+ - third-party-licenses.*.md
default-setup:
org:
model-packs: [ ${{ github.event.inputs.code_scanning_codeql_packs }} ]
diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml
deleted file mode 100644
index 92524ea17..000000000
--- a/.github/workflows/conformance.yml
+++ /dev/null
@@ -1,69 +0,0 @@
-name: Conformance Test
-
-on:
- pull_request:
-
-permissions:
- contents: read
-
-jobs:
- conformance:
- runs-on: ubuntu-latest
-
- steps:
- - name: Check out code
- uses: actions/checkout@v6
- with:
- # Fetch full history to access merge-base
- fetch-depth: 0
-
- - name: Set up Go
- uses: actions/setup-go@v6
- with:
- go-version-file: "go.mod"
-
- - name: Download dependencies
- run: go mod download
-
- - name: Run conformance test
- id: conformance
- run: |
- # Run conformance test, capture stdout for summary
- script/conformance-test > conformance-summary.txt 2>&1 || true
-
- # Output the summary
- cat conformance-summary.txt
-
- # Check result
- if grep -q "RESULT: ALL TESTS PASSED" conformance-summary.txt; then
- echo "status=passed" >> $GITHUB_OUTPUT
- else
- echo "status=differences" >> $GITHUB_OUTPUT
- fi
-
- - name: Generate Job Summary
- run: |
- # Add the full markdown report to the job summary
- echo "# MCP Server Conformance Report" >> $GITHUB_STEP_SUMMARY
- echo "" >> $GITHUB_STEP_SUMMARY
- echo "Comparing PR branch against merge-base with \`origin/main\`" >> $GITHUB_STEP_SUMMARY
- echo "" >> $GITHUB_STEP_SUMMARY
-
- # Extract and append the report content (skip the header since we added our own)
- tail -n +5 conformance-report/CONFORMANCE_REPORT.md >> $GITHUB_STEP_SUMMARY
-
- echo "" >> $GITHUB_STEP_SUMMARY
- echo "---" >> $GITHUB_STEP_SUMMARY
- echo "" >> $GITHUB_STEP_SUMMARY
-
- # Add interpretation note
- if [ "${{ steps.conformance.outputs.status }}" = "passed" ]; then
- echo "✅ **All conformance tests passed** - No behavioral differences detected." >> $GITHUB_STEP_SUMMARY
- else
- echo "⚠️ **Differences detected** - Review the diffs above to ensure changes are intentional." >> $GITHUB_STEP_SUMMARY
- echo "" >> $GITHUB_STEP_SUMMARY
- echo "Common expected differences:" >> $GITHUB_STEP_SUMMARY
- echo "- New tools/toolsets added" >> $GITHUB_STEP_SUMMARY
- echo "- Tool descriptions updated" >> $GITHUB_STEP_SUMMARY
- echo "- Capability changes (intentional improvements)" >> $GITHUB_STEP_SUMMARY
- fi
diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml
index ee63b9a87..3e5c8b70d 100644
--- a/.github/workflows/docker-publish.yml
+++ b/.github/workflows/docker-publish.yml
@@ -54,13 +54,13 @@ jobs:
# multi-platform images and export cache
# https://github.com/docker/setup-buildx-action
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
+ uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
# Login against a Docker registry except on PR
# https://github.com/docker/login-action
- name: Log into registry ${{ env.REGISTRY }}
if: github.event_name != 'pull_request'
- uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
+ uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -70,7 +70,7 @@ jobs:
# https://github.com/docker/metadata-action
- name: Extract Docker metadata
id: meta
- uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5.9.0
+ uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml
index 9fca37208..181a99560 100644
--- a/.github/workflows/go.yml
+++ b/.github/workflows/go.yml
@@ -14,6 +14,14 @@ jobs:
runs-on: ${{ matrix.os }}
steps:
+ - name: Force git to use LF
+ # This step is required on Windows to work around go mod tidy -diff issues caused by CRLF line endings.
+ # TODO: replace with a checkout option when https://github.com/actions/checkout/issues/226 is implemented
+ if: runner.os == 'Windows'
+ run: |
+ git config --global core.autocrlf false
+ git config --global core.eol lf
+
- name: Check out code
uses: actions/checkout@v6
@@ -22,8 +30,8 @@ jobs:
with:
go-version-file: "go.mod"
- - name: Download dependencies
- run: go mod download
+ - name: Tidy dependencies
+ run: go mod tidy -diff
- name: Run unit tests
run: script/test
diff --git a/.github/workflows/issue-labeler.yml b/.github/workflows/issue-labeler.yml
new file mode 100644
index 000000000..278bb8705
--- /dev/null
+++ b/.github/workflows/issue-labeler.yml
@@ -0,0 +1,19 @@
+name: Label issues for AI review
+on:
+ issues:
+ types:
+ - reopened
+ - opened
+jobs:
+ label_issues:
+ runs-on: ubuntu-latest
+ permissions:
+ issues: write
+ steps:
+ - name: Add AI review label to issue
+ run: gh issue edit "$NUMBER" --add-label "$LABELS"
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ GH_REPO: ${{ github.repository }}
+ NUMBER: ${{ github.event.issue.number }}
+ LABELS: "request ai review"
diff --git a/.github/workflows/license-check.yml b/.github/workflows/license-check.yml
index d9cb59fb7..940773275 100644
--- a/.github/workflows/license-check.yml
+++ b/.github/workflows/license-check.yml
@@ -1,9 +1,22 @@
-# Create a github action that runs the license check script and fails if it exits with a non-zero status
+# Automatically fix license files on PRs that need updates
+# Tries to auto-commit the fix, or comments with instructions if push fails
name: License Check
-on: [push, pull_request]
+on:
+ pull_request:
+ branches:
+ - main # Only run when PR targets main
+ paths:
+ - "**.go"
+ - go.mod
+ - go.sum
+ - ".github/licenses.tmpl"
+ - "script/licenses*"
+ - "third-party-licenses.*.md"
+ - "third-party/**"
permissions:
- contents: read
+ contents: write
+ pull-requests: write
jobs:
license-check:
@@ -13,9 +26,88 @@ jobs:
- name: Check out code
uses: actions/checkout@v6
+ # 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
with:
go-version-file: "go.mod"
- - name: check licenses
- run: ./script/licenses-check
+
+ # actions/setup-go does not setup the installed toolchain to be preferred over the system install,
+ # which causes go-licenses to raise "Package ... does not have module info" errors.
+ # For more information, https://github.com/google/go-licenses/issues/244#issuecomment-1885098633
+ - name: Regenerate licenses
+ env:
+ CI: "true"
+ run: |
+ export GOROOT=$(go env GOROOT)
+ export PATH=${GOROOT}/bin:$PATH
+ ./script/licenses
+
+ - name: Check for changes
+ id: changes
+ continue-on-error: true
+ run: script/licenses-check
+
+ - name: Commit and push fixes
+ if: steps.changes.outcome == 'failure'
+ continue-on-error: true
+ id: push
+ run: |
+ git config user.name "github-actions[bot]"
+ git config user.email "github-actions[bot]@users.noreply.github.com"
+ git add third-party-licenses.*.md third-party/
+ git commit -m "chore: regenerate license files" -m "Auto-generated by license-check workflow"
+ git push
+
+ - name: Check if already commented
+ if: steps.changes.outcome == 'failure' && steps.push.outcome == 'failure'
+ id: check_comment
+ uses: actions/github-script@v8
+ with:
+ script: |
+ const { data: comments } = await github.rest.issues.listComments({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: context.issue.number
+ });
+
+ const alreadyCommented = comments.some(comment =>
+ comment.user.login === 'github-actions[bot]' &&
+ comment.body.includes('## ⚠️ License files need updating')
+ );
+
+ core.setOutput('already_commented', alreadyCommented ? 'true' : 'false');
+
+ - 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@v8
+ with:
+ script: |
+ await github.rest.issues.createComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: context.issue.number,
+ body: `## ⚠️ License files need updating
+
+ The license files are out of date. I tried to fix them automatically but don't have permission to push to this branch.
+
+ **Please run:**
+ \`\`\`bash
+ script/licenses
+ git add third-party-licenses.*.md third-party/
+ git commit -m "chore: regenerate license files"
+ git push
+ \`\`\`
+
+ Alternatively, enable "Allow edits by maintainers" in the PR settings so I can fix it automatically.`
+ });
+
+ - name: Fail check if changes needed
+ if: steps.changes.outcome == 'failure'
+ run: exit 1
+
diff --git a/.github/workflows/mcp-diff.yml b/.github/workflows/mcp-diff.yml
new file mode 100644
index 000000000..ba9b59c6e
--- /dev/null
+++ b/.github/workflows/mcp-diff.yml
@@ -0,0 +1,72 @@
+name: MCP Server Diff
+
+on:
+ pull_request:
+ push:
+ branches: [main]
+ tags: ['v*']
+
+permissions:
+ contents: read
+
+jobs:
+ mcp-diff:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Check out code
+ uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+
+ - name: Run MCP Server Diff
+ uses: SamMorrowDrums/mcp-server-diff@v2.3.5
+ with:
+ setup_go: "true"
+ install_command: go mod download
+ start_command: go run ./cmd/github-mcp-server stdio
+ env_vars: |
+ GITHUB_PERSONAL_ACCESS_TOKEN=test-token
+ configurations: |
+ [
+ {"name": "default", "args": ""},
+ {"name": "read-only", "args": "--read-only"},
+ {"name": "dynamic-toolsets", "args": "--dynamic-toolsets"},
+ {"name": "read-only+dynamic", "args": "--read-only --dynamic-toolsets"},
+ {"name": "toolsets-repos", "args": "--toolsets=repos"},
+ {"name": "toolsets-issues", "args": "--toolsets=issues"},
+ {"name": "toolsets-context", "args": "--toolsets=context"},
+ {"name": "toolsets-pull_requests", "args": "--toolsets=pull_requests"},
+ {"name": "toolsets-repos,issues", "args": "--toolsets=repos,issues"},
+ {"name": "toolsets-issues,context", "args": "--toolsets=issues,context"},
+ {"name": "toolsets-all", "args": "--toolsets=all"},
+ {"name": "tools-get_me", "args": "--tools=get_me"},
+ {"name": "tools-get_me,list_issues", "args": "--tools=get_me,list_issues"},
+ {"name": "toolsets-repos+read-only", "args": "--toolsets=repos --read-only"},
+ {"name": "toolsets-all+dynamic", "args": "--toolsets=all --dynamic-toolsets"},
+ {"name": "toolsets-repos+dynamic", "args": "--toolsets=repos --dynamic-toolsets"},
+ {"name": "toolsets-repos,issues+dynamic", "args": "--toolsets=repos,issues --dynamic-toolsets"},
+ {
+ "name": "dynamic-tool-calls",
+ "args": "--dynamic-toolsets",
+ "custom_messages": [
+ {"id": 10, "name": "list_toolsets_before", "message": {"jsonrpc": "2.0", "id": 10, "method": "tools/call", "params": {"name": "list_available_toolsets", "arguments": {}}}},
+ {"id": 11, "name": "get_toolset_tools", "message": {"jsonrpc": "2.0", "id": 11, "method": "tools/call", "params": {"name": "get_toolset_tools", "arguments": {"toolset": "repos"}}}},
+ {"id": 12, "name": "enable_toolset", "message": {"jsonrpc": "2.0", "id": 12, "method": "tools/call", "params": {"name": "enable_toolset", "arguments": {"toolset": "repos"}}}},
+ {"id": 13, "name": "list_toolsets_after", "message": {"jsonrpc": "2.0", "id": 13, "method": "tools/call", "params": {"name": "list_available_toolsets", "arguments": {}}}}
+ ]
+ }
+ ]
+
+ - name: Add interpretation note
+ if: always()
+ run: |
+ echo "" >> $GITHUB_STEP_SUMMARY
+ echo "---" >> $GITHUB_STEP_SUMMARY
+ echo "" >> $GITHUB_STEP_SUMMARY
+ echo "ℹ️ **Note:** Differences may be intentional improvements." >> $GITHUB_STEP_SUMMARY
+ echo "" >> $GITHUB_STEP_SUMMARY
+ echo "Common expected differences:" >> $GITHUB_STEP_SUMMARY
+ echo "- New tools/toolsets added" >> $GITHUB_STEP_SUMMARY
+ echo "- Tool descriptions updated" >> $GITHUB_STEP_SUMMARY
+ echo "- Capability changes (intentional improvements)" >> $GITHUB_STEP_SUMMARY
diff --git a/.gitignore b/.gitignore
index 5684108b0..eedf65165 100644
--- a/.gitignore
+++ b/.gitignore
@@ -18,6 +18,8 @@ bin/
# binary
github-mcp-server
+mcpcurl
+e2e.test
.history
conformance-report/
diff --git a/.vscode/launch.json b/.vscode/launch.json
index cea7fd917..0d90e162a 100644
--- a/.vscode/launch.json
+++ b/.vscode/launch.json
@@ -23,6 +23,16 @@
"program": "cmd/github-mcp-server/main.go",
"args": ["stdio", "--read-only"],
"console": "integratedTerminal",
+ },
+ {
+ "name": "Launch http server",
+ "type": "go",
+ "request": "launch",
+ "mode": "auto",
+ "cwd": "${workspaceFolder}",
+ "program": "cmd/github-mcp-server/main.go",
+ "args": ["http", "--port", "8082"],
+ "console": "integratedTerminal",
}
]
}
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
index 92ed52581..6ff2babb8 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,4 +1,4 @@
-FROM golang:1.25.4-alpine AS build
+FROM golang:1.25.6-alpine AS build
ARG VERSION="dev"
# Set the working directory
@@ -14,7 +14,7 @@ RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
--mount=type=bind,target=. \
CGO_ENABLED=0 go build -ldflags="-s -w -X main.version=${VERSION} -X main.commit=$(git rev-parse HEAD) -X main.date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
- -o /bin/github-mcp-server cmd/github-mcp-server/main.go
+ -o /bin/github-mcp-server ./cmd/github-mcp-server
# Make a stage to run the app
FROM gcr.io/distroless/base-debian12
@@ -26,6 +26,8 @@ LABEL io.modelcontextprotocol.server.name="io.github.github/github-mcp-server"
WORKDIR /server
# Copy the binary from the build stage
COPY --from=build /bin/github-mcp-server .
+# Expose the default port
+EXPOSE 8082
# Set the entrypoint to the server binary
ENTRYPOINT ["/server/github-mcp-server"]
# Default arguments for ENTRYPOINT
diff --git a/README.md b/README.md
index ce6eb81cb..f0c1a7401 100644
--- a/README.md
+++ b/README.md
@@ -80,10 +80,14 @@ Alternatively, to manually configure VS Code, choose the appropriate JSON block
### Install in other MCP hosts
+
+- **[Copilot CLI](/docs/installation-guides/install-copilot-cli.md)** - Installation guide for GitHub Copilot CLI
- **[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
+- **[Claude Applications](/docs/installation-guides/install-claude.md)** - Installation guide for Claude Desktop and Claude Code CLI
+- **[Codex](/docs/installation-guides/install-codex.md)** - Installation guide for OpenAI Codex
- **[Cursor](/docs/installation-guides/install-cursor.md)** - Installation guide for Cursor IDE
- **[Windsurf](/docs/installation-guides/install-windsurf.md)** - Installation guide for Windsurf IDE
+- **[Rovo Dev CLI](/docs/installation-guides/install-rovo-dev-cli.md)** - Installation guide for Rovo Dev CLI
> **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.
@@ -95,6 +99,49 @@ See [Remote Server Documentation](docs/remote-server.md) for full details on rem
When no toolsets are specified, [default toolsets](#default-toolset) are used.
+#### Insiders Mode
+
+> **Try new features early!** The remote server offers an insiders version with early access to new features and experimental tools.
+
+
+| Using URL Path | Using Header |
+
+|
+
+```json
+{
+ "servers": {
+ "github": {
+ "type": "http",
+ "url": "https://api.githubcopilot.com/mcp/insiders"
+ }
+ }
+}
+```
+
+ |
+
+
+```json
+{
+ "servers": {
+ "github": {
+ "type": "http",
+ "url": "https://api.githubcopilot.com/mcp/",
+ "headers": {
+ "X-MCP-Insiders": "true"
+ }
+ }
+ }
+}
+```
+
+ |
+
+
+
+See [Remote Server Documentation](docs/remote-server.md#insiders-mode) for more details and examples.
+
#### GitHub Enterprise
##### GitHub Enterprise Cloud with data residency (ghe.com)
@@ -102,6 +149,7 @@ When no toolsets are specified, [default toolsets](#default-toolset) are used.
GitHub Enterprise Cloud can also make use of the remote server.
Example for `https://octocorp.ghe.com` with GitHub PAT token:
+
```
{
...
@@ -131,31 +179,37 @@ GitHub Enterprise Server does not support remote server hosting. Please refer to
### 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`.
+2. Once Docker is installed, you will also need to ensure Docker is running. The Docker image is available at `ghcr.io/github/github-mcp-server`. 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
@@ -178,6 +232,7 @@ To keep your GitHub PAT secure and reusable across different MCP hosts:
- **Regular rotation**: Update tokens periodically
- **Never commit**: Keep tokens out of version control
- **File permissions**: Restrict access to config files containing tokens
+
```bash
chmod 600 ~/.your-app/config.json
```
@@ -191,6 +246,7 @@ the hostname for GitHub Enterprise Server or GitHub Enterprise Cloud with data r
- For GitHub Enterprise Server, prefix the hostname with the `https://` URI scheme, as it otherwise defaults to `http://`, which GitHub Enterprise Server does not support.
- For GitHub Enterprise Cloud with data residency, use `https://YOURSUBDOMAIN.ghe.com` as the hostname.
+
``` json
"github": {
"command": "docker",
@@ -295,6 +351,7 @@ Optionally, you can add a similar example (i.e. without the mcp key) to a file c
For other MCP host applications, please refer to our installation guides:
+- **[Copilot CLI](docs/installation-guides/install-copilot-cli.md)** - Installation guide for GitHub Copilot CLI
- **[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 Code & Claude Desktop](docs/installation-guides/install-claude.md)** - Installation guide for Claude Code and Claude Desktop
- **[Cursor](docs/installation-guides/install-cursor.md)** - Installation guide for Cursor IDE
@@ -326,6 +383,17 @@ If you don't have Docker, you can use `go build` to build the binary in the
}
```
+### CLI utilities
+
+The `github-mcp-server` binary includes a few CLI subcommands that are helpful for debugging and exploring the server.
+
+- `github-mcp-server tool-search ""` searches tools by name, description, and input parameter names. Use `--max-results` to return more matches.
+Example (color output requires a TTY; use `docker run -t` (or `-it`) when running in Docker):
+```bash
+docker run -it --rm ghcr.io/github/github-mcp-server tool-search "issue" --max-results 5
+github-mcp-server tool-search "issue" --max-results 5
+```
+
## Tool Configuration
The GitHub MCP Server supports enabling or disabling specific groups of functionalities via the `--toolsets` flag. This allows you to control which GitHub API capabilities are available to your AI tools. Enabling only the toolsets that you need can help the LLM with tool choice and reduce the context size.
@@ -347,6 +415,7 @@ To specify toolsets you want available to the LLM, you can pass an allow-list in
```
2. **Using Environment Variable**:
+
```bash
GITHUB_TOOLSETS="repos,issues,pull_requests,actions,code_security" ./github-mcp-server
```
@@ -364,23 +433,29 @@ You can also configure specific tools using the `--tools` flag. Tools can be use
```
2. **Using Environment Variable**:
+
```bash
GITHUB_TOOLS="get_file_contents,issue_read,create_pull_request" ./github-mcp-server
```
3. **Combining with Toolsets** (additive):
+
```bash
github-mcp-server --toolsets repos,issues --tools get_gist
```
+
This registers all tools from `repos` and `issues` toolsets, plus `get_gist`.
4. **Combining with Dynamic Toolsets** (additive):
+
```bash
github-mcp-server --tools get_file_contents --dynamic-toolsets
```
+
This registers `get_file_contents` plus the dynamic toolset tools (`enable_toolset`, `list_available_toolsets`, `get_toolset_tools`).
**Important Notes:**
+
- Tools, toolsets, and dynamic toolsets can all be used together
- Read-only mode takes priority: write tools are skipped if `--read-only` is set, even if explicitly requested via `--tools`
- Tool names must match exactly (e.g., `get_file_contents`, not `getFileContents`). Invalid tool names will cause the server to fail at startup with an error message
@@ -393,7 +468,7 @@ When using Docker, you can pass the toolsets as environment variables:
```bash
docker run -i --rm \
-e GITHUB_PERSONAL_ACCESS_TOKEN= \
- -e GITHUB_TOOLSETS="repos,issues,pull_requests,actions,code_security,experiments" \
+ -e GITHUB_TOOLSETS="repos,issues,pull_requests,actions,code_security" \
ghcr.io/github/github-mcp-server
```
@@ -433,9 +508,11 @@ GITHUB_TOOLSETS="all" ./github-mcp-server
```
#### "default" toolset
+
The default toolset `default` is the configuration that gets passed to the server if no toolsets are specified.
The default configuration is:
+
- context
- repos
- issues
@@ -448,6 +525,31 @@ To keep the default configuration and add additional toolsets:
GITHUB_TOOLSETS="default,stargazers" ./github-mcp-server
```
+### Insiders Mode
+
+The local GitHub MCP Server offers an insiders version with early access to new features and experimental tools.
+
+1. **Using Command Line Argument**:
+
+ ```bash
+ ./github-mcp-server --insiders
+ ```
+
+2. **Using Environment Variable**:
+
+ ```bash
+ GITHUB_INSIDERS=true ./github-mcp-server
+ ```
+
+When using Docker:
+
+```bash
+docker run -i --rm \
+ -e GITHUB_PERSONAL_ACCESS_TOKEN= \
+ -e GITHUB_INSIDERS=true \
+ ghcr.io/github/github-mcp-server
+```
+
### Available Toolsets
The following sets of tools are available:
@@ -491,6 +593,7 @@ The following sets of tools are available:
Actions
- **actions_get** - Get details of GitHub Actions resources (workflows, workflow runs, jobs, and artifacts)
+ - **Required OAuth Scopes**: `repo`
- `method`: The method to execute (string, required)
- `owner`: Repository owner (string, required)
- `repo`: Repository name (string, required)
@@ -502,6 +605,7 @@ The following sets of tools are available:
(string, required)
- **actions_list** - List GitHub Actions workflows in a repository
+ - **Required OAuth Scopes**: `repo`
- `method`: The action to perform (string, required)
- `owner`: Repository owner (string, required)
- `page`: Page number for pagination (default: 1) (number, optional)
@@ -509,13 +613,14 @@ The following sets of tools are available:
- `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 ID or workflow file name (e.g. ci.yaml) for 'list_workflow_runs' method, or omit to list all workflow runs in the repository.
- Provide a workflow run ID for 'list_workflow_jobs' and 'list_workflow_run_artifacts' methods.
(string, optional)
- `workflow_jobs_filter`: Filters for workflow jobs. **ONLY** used when method is 'list_workflow_jobs' (object, optional)
- `workflow_runs_filter`: Filters for workflow runs. **ONLY** used when method is 'list_workflow_runs' (object, optional)
- **actions_run_trigger** - Trigger GitHub Actions workflow actions
+ - **Required OAuth Scopes**: `repo`
- `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)
@@ -524,31 +629,8 @@ The following sets of tools are available:
- `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
- - `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
- - `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
- - `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
- - `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)
- - `repo`: Repository name (string, required)
- - `return_content`: Returns actual log content instead of URLs (boolean, optional)
- - `run_id`: Workflow run ID (required when using failed_only) (number, optional)
- - `tail_lines`: Number of lines to return from the end of the log (number, optional)
-
- **get_job_logs** - Get GitHub Actions workflow job logs
+ - **Required OAuth Scopes**: `repo`
- `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)
@@ -557,70 +639,6 @@ The following sets of tools are available:
- `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
- - `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
- - `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
- - `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
- - `filter`: Filters jobs by their completed_at timestamp (string, optional)
- - `owner`: Repository owner (string, required)
- - `page`: Page number for pagination (min 1) (number, optional)
- - `perPage`: Results per page for pagination (min 1, max 100) (number, optional)
- - `repo`: Repository name (string, required)
- - `run_id`: The unique identifier of the workflow run (number, required)
-
-- **list_workflow_run_artifacts** - List workflow artifacts
- - `owner`: Repository owner (string, required)
- - `page`: Page number for pagination (min 1) (number, optional)
- - `perPage`: Results per page for pagination (min 1, max 100) (number, optional)
- - `repo`: Repository name (string, required)
- - `run_id`: The unique identifier of the workflow run (number, required)
-
-- **list_workflow_runs** - List workflow runs
- - `actor`: Returns someone's workflow runs. Use the login for the user who created the workflow run. (string, optional)
- - `branch`: Returns workflow runs associated with a branch. Use the name of the branch. (string, optional)
- - `event`: Returns workflow runs for a specific event type (string, optional)
- - `owner`: Repository owner (string, required)
- - `page`: Page number for pagination (min 1) (number, optional)
- - `perPage`: Results per page for pagination (min 1, max 100) (number, optional)
- - `repo`: Repository name (string, required)
- - `status`: Returns workflow runs with the check run status (string, optional)
- - `workflow_id`: The workflow ID or workflow file name (string, required)
-
-- **list_workflows** - List workflows
- - `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
- - `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
- - `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
- - `inputs`: Inputs the workflow accepts (object, optional)
- - `owner`: Repository owner (string, required)
- - `ref`: The git reference for the workflow. The reference can be a branch or tag name. (string, required)
- - `repo`: Repository name (string, required)
- - `workflow_id`: The workflow ID (numeric) or workflow file name (e.g., main.yml, ci.yaml) (string, required)
-
@@ -628,11 +646,15 @@ The following sets of tools are available:
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 +672,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 +689,15 @@ The following sets of tools are available:
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 +710,13 @@ The following sets of tools are available:
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 +724,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 +745,7 @@ The following sets of tools are available:
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 +761,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 +774,7 @@ The following sets of tools are available:
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 +788,28 @@ The following sets of tools are available:
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)
+ - `custom_instructions`: Optional custom instructions to guide the agent beyond the issue body. Use this to provide additional context, constraints, or guidance that is not captured in the issue description (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 +824,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 +844,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 +861,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 +871,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 +893,13 @@ The following sets of tools are available:
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 +909,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 +920,16 @@ The following sets of tools are available:
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 +939,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 +962,8 @@ The following sets of tools are available:
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)
@@ -916,69 +976,43 @@ The following sets of tools are available:
Projects
-- **add_project_item** - Add project item
- - `item_id`: The numeric ID of the issue or pull request to add to the project. (number, required)
- - `item_type`: The item's type, either issue or pull_request. (string, required)
- - `owner`: If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive. (string, required)
- - `owner_type`: Owner type (string, required)
- - `project_number`: The project's number. (number, required)
-
-- **delete_project_item** - Delete project item
- - `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
- - `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
- - `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
- - `fields`: Specific list of field IDs to include in the response (e.g. ["102589", "985201", "169875"]). If not provided, only the title field is included. (string[], optional)
- - `item_id`: The item's ID. (number, required)
- - `owner`: If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive. (string, required)
- - `owner_type`: Owner type (string, required)
- - `project_number`: The project's number. (number, required)
-
-- **list_project_fields** - List project fields
- - `after`: Forward pagination cursor from previous pageInfo.nextCursor. (string, optional)
- - `before`: Backward pagination cursor from previous pageInfo.prevCursor (rare). (string, optional)
- - `owner`: If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive. (string, required)
- - `owner_type`: Owner type (string, required)
- - `per_page`: Results per page (max 50) (number, optional)
- - `project_number`: The project's number. (number, required)
-
-- **list_project_items** - List project items
- - `after`: Forward pagination cursor from previous pageInfo.nextCursor. (string, optional)
- - `before`: Backward pagination cursor from previous pageInfo.prevCursor (rare). (string, optional)
- - `fields`: Field IDs to include (e.g. ["102589", "985201"]). CRITICAL: Always provide to get field values. Without this, only titles returned. (string[], optional)
- - `owner`: If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive. (string, required)
- - `owner_type`: Owner type (string, required)
- - `per_page`: Results per page (max 50) (number, optional)
+- **projects_get** - Get details of GitHub Projects resources
+ - **Required OAuth Scopes**: `read:project`
+ - **Accepted OAuth Scopes**: `project`, `read:project`
+ - `field_id`: The field's ID. Required for 'get_project_field' method. (number, optional)
+ - `fields`: Specific list of field IDs to include in the response when getting a project item (e.g. ["102589", "985201", "169875"]). If not provided, only the title field is included. Only used for 'get_project_item' method. (string[], optional)
+ - `item_id`: The item's ID. Required for 'get_project_item' method. (number, optional)
+ - `method`: The method to execute (string, required)
+ - `owner`: The owner (user or organization login). The name is not case sensitive. (string, required)
+ - `owner_type`: Owner type (user or org). If not provided, will be automatically detected. (string, optional)
- `project_number`: The project's number. (number, required)
- - `query`: Query string for advanced filtering of project items using GitHub's project filtering syntax. (string, optional)
-- **list_projects** - List projects
+- **projects_list** - List GitHub Projects resources
+ - **Required OAuth Scopes**: `read:project`
+ - **Accepted OAuth Scopes**: `project`, `read:project`
- `after`: Forward pagination cursor from previous pageInfo.nextCursor. (string, optional)
- `before`: Backward pagination cursor from previous pageInfo.prevCursor (rare). (string, optional)
- - `owner`: If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive. (string, required)
- - `owner_type`: Owner type (string, required)
+ - `fields`: Field IDs to include when listing project items (e.g. ["102589", "985201"]). CRITICAL: Always provide to get field values. Without this, only titles returned. Only used for 'list_project_items' method. (string[], optional)
+ - `method`: The action to perform (string, required)
+ - `owner`: The owner (user or organization login). The name is not case sensitive. (string, required)
+ - `owner_type`: Owner type (user or org). If not provided, will automatically try both. (string, optional)
- `per_page`: Results per page (max 50) (number, optional)
- - `query`: Filter projects by title text and open/closed state; permitted qualifiers: is:open, is:closed; examples: "roadmap is:open", "is:open feature planning". (string, optional)
-
-- **update_project_item** - Update project item
- - `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)
+ - `project_number`: The project's number. Required for 'list_project_fields' and 'list_project_items' methods. (number, optional)
+ - `query`: Filter/query string. For list_projects: filter by title text and state (e.g. "roadmap is:open"). For list_project_items: advanced filtering using GitHub's project filtering syntax. (string, optional)
+
+- **projects_write** - Modify GitHub Project items
+ - **Required OAuth Scopes**: `project`
+ - `issue_number`: The issue number (use when item_type is 'issue' for 'add_project_item' method). Provide either issue_number or pull_request_number. (number, optional)
+ - `item_id`: The project item ID. Required for 'update_project_item' and 'delete_project_item' methods. (number, optional)
+ - `item_owner`: The owner (user or organization) of the repository containing the issue or pull request. Required for 'add_project_item' method. (string, optional)
+ - `item_repo`: The name of the repository containing the issue or pull request. Required for 'add_project_item' method. (string, optional)
+ - `item_type`: The item's type, either issue or pull_request. Required for 'add_project_item' method. (string, optional)
+ - `method`: The method to execute (string, required)
+ - `owner`: The project owner (user or organization login). The name is not case sensitive. (string, required)
+ - `owner_type`: Owner type (user or org). If not provided, will be automatically detected. (string, optional)
- `project_number`: The project's number. (number, required)
- - `updated_field`: Object consisting of the ID of the project field to update and the new value for the field. To clear the field, set value to null. Example: {"id": 123456, "value": "New Value"} (object, required)
+ - `pull_request_number`: The pull request number (use when item_type is 'pull_request' for 'add_project_item' method). Provide either issue_number or pull_request_number. (number, optional)
+ - `updated_field`: Object consisting of the ID of the project field to update and the new value for the field. To clear the field, set value to null. Example: {"id": 123456, "value": "New Value"}. Required for 'update_project_item' method. (object, optional)
@@ -987,6 +1021,7 @@ The following sets of tools are available:
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)
@@ -998,7 +1033,16 @@ The following sets of tools are available:
- `startSide`: For multi-line comments, the starting side of the diff that the comment applies to. LEFT indicates the previous state, RIGHT indicates the new state (string, optional)
- `subjectType`: The level at which the comment is targeted (string, required)
+- **add_reply_to_pull_request_comment** - Add reply to pull request comment
+ - **Required OAuth Scopes**: `repo`
+ - `body`: The text of the reply (string, required)
+ - `commentId`: The ID of the comment to reply to (number, required)
+ - `owner`: Repository owner (string, required)
+ - `pullNumber`: Pull request number (number, required)
+ - `repo`: Repository name (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 +1053,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 +1065,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 +1074,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 +1092,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 +1102,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 +1118,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 +1131,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 +1144,14 @@ The following sets of tools are available:
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 +1161,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 +1169,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 +1177,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 +1192,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 +1200,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 +1233,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 +1255,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 +1263,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 +1278,15 @@ The following sets of tools are available:
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 +1300,13 @@ The following sets of tools are available:
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 +1320,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 +1343,7 @@ The following sets of tools are available:
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 +1351,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 +1367,7 @@ The following sets of tools are available:
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)
@@ -1298,12 +1383,12 @@ The following sets of tools are available:
Copilot
-- **create_pull_request_with_copilot** - Perform task with GitHub Copilot coding agent
- - `owner`: Repository owner. You can guess the owner, but confirm it with the user before proceeding. (string, required)
- - `repo`: Repository name. You can guess the repository name, but confirm it with the user before proceeding. (string, required)
- - `problem_statement`: Detailed description of the task to be performed (e.g., 'Implement a feature that does X', 'Fix bug Y', etc.) (string, required)
- - `title`: Title for the pull request that will be created (string, required)
- - `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)
+- **create_pull_request_with_copilot** - Perform task with GitHub Copilot coding agent
+ - `owner`: Repository owner. You can guess the owner, but confirm it with the user before proceeding. (string, required)
+ - `repo`: Repository name. You can guess the repository name, but confirm it with the user before proceeding. (string, required)
+ - `problem_statement`: Detailed description of the task to be performed (e.g., 'Implement a feature that does X', 'Fix bug Y', etc.) (string, required)
+ - `title`: Title for the pull request that will be created (string, required)
+ - `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)
@@ -1311,19 +1396,21 @@ The following sets of tools are available:
Copilot Spaces
-- **get_copilot_space** - Get Copilot Space
- - `owner`: The owner of the space. (string, required)
- - `name`: The name of the space. (string, required)
+- **get_copilot_space** - Get Copilot Space
+ - `owner`: The owner of the space. (string, required)
+ - `name`: The name of the space. (string, required)
+
+- **list_copilot_spaces** - List Copilot Spaces
-- **list_copilot_spaces** - List Copilot Spaces
GitHub Support Docs Search
-- **github_support_docs_search** - Retrieve documentation relevant to answer GitHub product and support questions. Support topics include: GitHub Actions Workflows, Authentication, GitHub Support Inquiries, Pull Request Practices, Repository Maintenance, GitHub Pages, GitHub Packages, GitHub Discussions, Copilot Spaces
- - `query`: Input from the user about the question they need answered. This is the latest raw unedited user message. You should ALWAYS leave the user message as it is, you should never modify it. (string, required)
+- **github_support_docs_search** - Retrieve documentation relevant to answer GitHub product and support questions. Support topics include: GitHub Actions Workflows, Authentication, GitHub Support Inquiries, Pull Request Practices, Repository Maintenance, GitHub Pages, GitHub Packages, GitHub Discussions, Copilot Spaces
+ - `query`: Input from the user about the question they need answered. This is the latest raw unedited user message. You should ALWAYS leave the user message as it is, you should never modify it. (string, required)
+
## Dynamic Tool Discovery
diff --git a/cmd/github-mcp-server/generate_docs.go b/cmd/github-mcp-server/generate_docs.go
index b40e3e2f4..78fd6c40a 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,9 @@ 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.
+ // Build() can only fail if WithTools specifies invalid tools - not used here
+ r, _ := github.NewInventory(t).WithToolsets([]string{"all"}).Build()
// Generate toolsets documentation
toolsetsDoc := generateToolsetsDoc(r)
@@ -153,9 +154,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 +189,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 +199,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 +267,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 {
@@ -335,7 +342,8 @@ func generateRemoteToolsetsDoc() string {
t, _ := translations.TranslationHelper()
// Build inventory - stateless
- r := github.NewInventory(t).Build()
+ // Build() can only fail if WithTools specifies invalid tools - not used here
+ r, _ := github.NewInventory(t).Build()
// Generate table header (icon is combined with Name column)
buf.WriteString("| Name | Description | API URL | 1-Click Install (VS Code) | Read-only Link | 1-Click Read-only Install (VS Code) |\n")
@@ -343,14 +351,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 +373,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 +398,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 +413,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..d8b8bf392
--- /dev/null
+++ b/cmd/github-mcp-server/list_scopes.go
@@ -0,0 +1,294 @@
+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, err := inventoryBuilder.Build()
+ if err != nil {
+ return fmt.Errorf("failed to build inventory: %w", err)
+ }
+
+ // 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/cmd/github-mcp-server/main.go b/cmd/github-mcp-server/main.go
index cfb68be4e..b8002d456 100644
--- a/cmd/github-mcp-server/main.go
+++ b/cmd/github-mcp-server/main.go
@@ -9,6 +9,7 @@ import (
"github.com/github/github-mcp-server/internal/ghmcp"
"github.com/github/github-mcp-server/pkg/github"
+ ghhttp "github.com/github/github-mcp-server/pkg/http"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
"github.com/spf13/viper"
@@ -83,11 +84,37 @@ var (
LogFilePath: viper.GetString("log-file"),
ContentWindowSize: viper.GetInt("content-window-size"),
LockdownMode: viper.GetBool("lockdown-mode"),
+ InsidersMode: viper.GetBool("insiders"),
RepoAccessCacheTTL: &ttl,
}
return ghmcp.RunStdioServer(stdioServerConfig)
},
}
+
+ httpCmd = &cobra.Command{
+ Use: "http",
+ Short: "Start HTTP server",
+ Long: `Start an HTTP server that listens for MCP requests over HTTP.`,
+ RunE: func(_ *cobra.Command, _ []string) error {
+ ttl := viper.GetDuration("repo-access-cache-ttl")
+ httpConfig := ghhttp.ServerConfig{
+ Version: version,
+ Host: viper.GetString("host"),
+ Port: viper.GetInt("port"),
+ BaseURL: viper.GetString("base-url"),
+ ResourcePath: viper.GetString("base-path"),
+ ExportTranslations: viper.GetBool("export-translations"),
+ EnableCommandLogging: viper.GetBool("enable-command-logging"),
+ LogFilePath: viper.GetString("log-file"),
+ ContentWindowSize: viper.GetInt("content-window-size"),
+ LockdownMode: viper.GetBool("lockdown-mode"),
+ RepoAccessCacheTTL: &ttl,
+ ScopeChallenge: viper.GetBool("scope-challenge"),
+ }
+
+ return ghhttp.RunHTTPServer(httpConfig)
+ },
+ }
)
func init() {
@@ -108,8 +135,15 @@ func init() {
rootCmd.PersistentFlags().String("gh-host", "", "Specify the GitHub hostname (for GitHub Enterprise etc.)")
rootCmd.PersistentFlags().Int("content-window-size", 5000, "Specify the content window size")
rootCmd.PersistentFlags().Bool("lockdown-mode", false, "Enable lockdown mode")
+ rootCmd.PersistentFlags().Bool("insiders", false, "Enable insiders features")
rootCmd.PersistentFlags().Duration("repo-access-cache-ttl", 5*time.Minute, "Override the repo access cache TTL (e.g. 1m, 0s to disable)")
+ // HTTP-specific flags
+ httpCmd.Flags().Int("port", 8082, "HTTP server port")
+ httpCmd.Flags().String("base-url", "", "Base URL where this server is publicly accessible (for OAuth resource metadata)")
+ httpCmd.Flags().String("base-path", "", "Externally visible base path for the HTTP server (for OAuth resource metadata)")
+ httpCmd.Flags().Bool("scope-challenge", false, "Enable OAuth scope challenge responses")
+
// Bind flag to viper
_ = viper.BindPFlag("toolsets", rootCmd.PersistentFlags().Lookup("toolsets"))
_ = viper.BindPFlag("tools", rootCmd.PersistentFlags().Lookup("tools"))
@@ -122,10 +156,15 @@ func init() {
_ = viper.BindPFlag("host", rootCmd.PersistentFlags().Lookup("gh-host"))
_ = viper.BindPFlag("content-window-size", rootCmd.PersistentFlags().Lookup("content-window-size"))
_ = viper.BindPFlag("lockdown-mode", rootCmd.PersistentFlags().Lookup("lockdown-mode"))
+ _ = viper.BindPFlag("insiders", rootCmd.PersistentFlags().Lookup("insiders"))
_ = viper.BindPFlag("repo-access-cache-ttl", rootCmd.PersistentFlags().Lookup("repo-access-cache-ttl"))
-
+ _ = viper.BindPFlag("port", httpCmd.Flags().Lookup("port"))
+ _ = viper.BindPFlag("base-url", httpCmd.Flags().Lookup("base-url"))
+ _ = viper.BindPFlag("base-path", httpCmd.Flags().Lookup("base-path"))
+ _ = viper.BindPFlag("scope-challenge", httpCmd.Flags().Lookup("scope-challenge"))
// Add subcommands
rootCmd.AddCommand(stdioCmd)
+ rootCmd.AddCommand(httpCmd)
}
func initConfig() {
@@ -133,7 +172,6 @@ func initConfig() {
viper.SetEnvPrefix("github")
viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_"))
viper.AutomaticEnv()
-
}
func main() {
diff --git a/cmd/mcpcurl/README.md b/cmd/mcpcurl/README.md
index 717ea207f..e1227d585 100644
--- a/cmd/mcpcurl/README.md
+++ b/cmd/mcpcurl/README.md
@@ -16,7 +16,7 @@ be executed against the configured MCP server.
## Installation
### Prerequisites
-- Go 1.21 or later
+- Go 1.24 or later
- Access to the GitHub MCP Server from either Docker or local build
### Build from Source
diff --git a/cmd/mcpcurl/mcpcurl b/cmd/mcpcurl/mcpcurl
deleted file mode 100755
index 6ea4eeda6..000000000
Binary files a/cmd/mcpcurl/mcpcurl and /dev/null differ
diff --git a/docs/installation-guides/README.md b/docs/installation-guides/README.md
index be967f81d..4a300e3f4 100644
--- a/docs/installation-guides/README.md
+++ b/docs/installation-guides/README.md
@@ -3,6 +3,7 @@
This directory contains detailed installation instructions for the GitHub MCP Server across different host applications and IDEs. Choose the guide that matches your development environment.
## Installation Guides by Host Application
+- **[Copilot CLI](install-copilot-cli.md)** - Installation guide for GitHub Copilot CLI
- **[GitHub Copilot in other IDEs](install-other-copilot-ides.md)** - Installation for JetBrains, Visual Studio, Eclipse, and Xcode with GitHub Copilot
- **[Antigravity](install-antigravity.md)** - Installation for Google Antigravity IDE
- **[Claude Applications](install-claude.md)** - Installation guide for Claude Web, Claude Desktop and Claude Code CLI
@@ -15,6 +16,7 @@ This directory contains detailed installation instructions for the GitHub MCP Se
| Host Application | Local GitHub MCP Support | Remote GitHub MCP Support | Prerequisites | Difficulty |
|-----------------|---------------|----------------|---------------|------------|
+| Copilot CLI | ✅ | ✅ PAT + ❌ No OAuth | Docker or Go build, GitHub PAT | Easy |
| Copilot in VS Code | ✅ | ✅ Full (OAuth + PAT) | Local: Docker or Go build, GitHub PAT
Remote: VS Code 1.101+ | Easy |
| Copilot Coding Agent | ✅ | ✅ Full (on by default; no auth needed) | Any _paid_ copilot license | Default on |
| Copilot in Visual Studio | ✅ | ✅ Full (OAuth + PAT) | Local: Docker or Go build, GitHub PAT
Remote: Visual Studio 17.14+ | Easy |
diff --git a/docs/installation-guides/install-claude.md b/docs/installation-guides/install-claude.md
index 1a5b789f4..05e3c3739 100644
--- a/docs/installation-guides/install-claude.md
+++ b/docs/installation-guides/install-claude.md
@@ -28,22 +28,34 @@ 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).
+>
+> **Windows / CLI note**: `claude mcp add-json` may return `Invalid input` when adding an HTTP server. If that happens, use the legacy `claude mcp add --transport http ...` command format below.
+
+1. Run the following command in the terminal (not in Claude Code CLI):
```bash
-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 +84,28 @@ 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)"
+```
+
+#### Windows (PowerShell)
+
+If you see `missing required argument 'name'`, put the server name immediately after `claude mcp add`:
+
+```powershell
+$pat = "YOUR_GITHUB_PAT"
+claude mcp add github --transport http https://api.githubcopilot.com/mcp/ -H "Authorization: Bearer $pat"
+```
+
---
## Claude Desktop
@@ -161,7 +195,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-copilot-cli.md b/docs/installation-guides/install-copilot-cli.md
new file mode 100644
index 000000000..5f95a03ef
--- /dev/null
+++ b/docs/installation-guides/install-copilot-cli.md
@@ -0,0 +1,136 @@
+# Install GitHub MCP Server in Copilot CLI
+
+## Prerequisites
+
+1. Copilot CLI installed (see [official Copilot CLI documentation](https://docs.github.com/en/copilot/concepts/agents/about-copilot-cli))
+2. [GitHub Personal Access Token](https://github.com/settings/personal-access-tokens/new) with appropriate scopes
+3. For local installation: [Docker](https://www.docker.com/) installed and running
+
+
+Storing Your PAT Securely
+
+
+To set your PAT as an environment variable:
+
+```bash
+# Add to your shell profile (~/.bashrc, ~/.zshrc, etc.)
+export GITHUB_PERSONAL_ACCESS_TOKEN=your_token_here
+```
+
+
+
+## GitHub MCP Server Configuration
+
+You can configure the GitHub MCP server in Copilot CLI using either the interactive command or by manually editing the configuration file.
+
+### Method 1: Interactive Setup (Recommended)
+
+Use the Copilot CLI to interactively add the MCP server:
+
+```bash
+/mcp add
+```
+
+Follow the prompts to configure the GitHub MCP server.
+
+### Method 2: Manual Configuration
+
+Create or edit the configuration file `~/.copilot/mcp-config.json` and add one of the following configurations:
+
+#### Remote Server
+
+Connect to the hosted MCP server:
+
+```json
+{
+ "mcpServers": {
+ "github": {
+ "url": "https://api.githubcopilot.com/mcp/",
+ "headers": {
+ "Authorization": "Bearer ${GITHUB_PERSONAL_ACCESS_TOKEN}"
+ }
+ }
+ }
+}
+```
+
+#### Local Docker
+
+With Docker running, you can run the GitHub MCP server in a container:
+
+```json
+{
+ "mcpServers": {
+ "github": {
+ "command": "docker",
+ "args": [
+ "run",
+ "-i",
+ "--rm",
+ "-e",
+ "GITHUB_PERSONAL_ACCESS_TOKEN",
+ "ghcr.io/github/github-mcp-server"
+ ],
+ "env": {
+ "GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_PERSONAL_ACCESS_TOKEN}"
+ }
+ }
+ }
+}
+```
+
+#### Binary
+
+You can download the latest binary release from the [GitHub releases page](https://github.com/github/github-mcp-server/releases) or build it from source by running `go build -o github-mcp-server ./cmd/github-mcp-server`.
+
+Then, replacing `/path/to/binary` with the actual path to your binary, configure Copilot CLI with:
+
+```json
+{
+ "mcpServers": {
+ "github": {
+ "command": "/path/to/binary",
+ "args": ["stdio"],
+ "env": {
+ "GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_PERSONAL_ACCESS_TOKEN}"
+ }
+ }
+ }
+}
+```
+
+## Verification
+
+To verify that the GitHub MCP server has been configured:
+
+1. Start or restart Copilot CLI
+2. The GitHub tools should be available for use in your conversations
+
+## Troubleshooting
+
+### Local Server Issues
+
+- **Docker errors**: Ensure Docker Desktop is running
+ ```bash
+ docker --version
+ ```
+- **Image pull failures**: Try `docker logout ghcr.io` then retry
+- **Docker not found**: Install Docker Desktop and ensure it's running
+
+### Authentication Issues
+
+- **Invalid PAT**: Verify your GitHub PAT has correct scopes:
+ - `repo` - Repository operations
+ - `read:packages` - Docker image access (if using Docker)
+- **Token expired**: Generate a new GitHub PAT
+
+### Configuration Issues
+
+- **Invalid JSON**: Validate your configuration:
+ ```bash
+ cat ~/.copilot/mcp-config.json | jq .
+ ```
+
+## References
+
+- [Copilot CLI Documentation](https://docs.github.com/en/copilot/concepts/agents/about-copilot-cli)
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..cad9ed604 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) |
| ---- | ----------- | ------- | ------------------------- | -------------- | ----------------------------------- |
-| 
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) |
-| 
Actions | GitHub Actions workflows and CI/CD operations | https://api.githubcopilot.com/mcp/x/actions | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-actions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Factions%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/actions/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-actions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Factions%2Freadonly%22%7D) |
-| 
Code Security | Code security related tools, such as GitHub Code Scanning | https://api.githubcopilot.com/mcp/x/code_security | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_security&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcode_security%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/code_security/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_security&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcode_security%2Freadonly%22%7D) |
-| 
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) |
-| 
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) |
-| 
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 | 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) |
-| 
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) |
-| 
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) |
-| 
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) |
-| 
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) |
-| 
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) |
-| 
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) |
-| 
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) |
-| 
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) |
-| 
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) |
-| 
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) |
-| 
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) |
+| 
`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) |
+| 
`actions` | GitHub Actions workflows and CI/CD operations | https://api.githubcopilot.com/mcp/x/actions | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-actions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Factions%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/actions/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-actions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Factions%2Freadonly%22%7D) |
+| 
`code_security` | Code security related tools, such as GitHub Code Scanning | https://api.githubcopilot.com/mcp/x/code_security | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_security&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcode_security%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/code_security/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_security&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcode_security%2Freadonly%22%7D) |
+| 
`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) |
+| 
`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) |
+| 
`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` | 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) |
+| 
`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) |
+| 
`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) |
+| 
`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) |
+| 
`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) |
+| 
`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) |
+| 
`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) |
+| 
`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) |
+| 
`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) |
+| 
`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) |
+| 
`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) |
+| 
`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 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 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) |
-| 
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 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_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) |
+| 
`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
@@ -67,6 +67,9 @@ The Remote GitHub MCP server has optional headers equivalent to the Local server
- `X-MCP-Lockdown`: Enables lockdown mode, hiding public issue details created by users without push access.
- Equivalent to `GITHUB_LOCKDOWN_MODE` env var for Local server.
- If this header is empty, "false", "f", "no", "n", "0", or "off" (ignoring whitespace and case), it will be interpreted as false. All other values are interpreted as true.
+- `X-MCP-Insiders`: Enables insiders mode for early access to new features.
+ - Equivalent to `GITHUB_INSIDERS` env var or `--insiders` flag for Local server.
+ - If this header is empty, "false", "f", "no", "n", "0", or "off" (ignoring whitespace and case), it will be interpreted as false. All other values are interpreted as true.
> **Looking for examples?** See the [Server Configuration Guide](./server-configuration.md) for common recipes like minimal setups, read-only mode, and combining tools with toolsets.
@@ -84,18 +87,51 @@ Example:
}
```
+### Insiders Mode
+
+The remote GitHub MCP Server offers an insiders version with early access to new features and experimental tools. You can enable insiders mode in two ways:
+
+1. **Via URL path** - Append `/insiders` to the URL:
+
+ ```json
+ {
+ "type": "http",
+ "url": "https://api.githubcopilot.com/mcp/insiders"
+ }
+ ```
+
+2. **Via header** - Set the `X-MCP-Insiders` header to `true`:
+
+ ```json
+ {
+ "type": "http",
+ "url": "https://api.githubcopilot.com/mcp/",
+ "headers": {
+ "X-MCP-Insiders": "true"
+ }
+ }
+ ```
+
+Both methods can be combined with other path modifiers (like `/readonly`) and headers.
+
### URL Path Parameters
The Remote GitHub MCP server supports the following URL path patterns:
- `/` - Default toolset (see ["default" toolset](../README.md#default-toolset))
- `/readonly` - Default toolset in read-only mode
+- `/insiders` - Default toolset with insiders mode enabled
+- `/readonly/insiders` - Default toolset in read-only mode with insiders mode enabled
- `/x/all` - All available toolsets
- `/x/all/readonly` - All available toolsets in read-only mode
+- `/x/all/insiders` - All available toolsets with insiders mode enabled
+- `/x/all/readonly/insiders` - All available toolsets in read-only mode with insiders mode enabled
- `/x/{toolset}` - Single specific toolset
- `/x/{toolset}/readonly` - Single specific toolset in read-only mode
+- `/x/{toolset}/insiders` - Single specific toolset with insiders mode enabled
+- `/x/{toolset}/readonly/insiders` - Single specific toolset in read-only mode with insiders mode enabled
-Note: `{toolset}` can only be a single toolset, not a comma-separated list. To combine multiple toolsets, use the `X-MCP-Toolsets` header instead.
+Note: `{toolset}` can only be a single toolset, not a comma-separated list. To combine multiple toolsets, use the `X-MCP-Toolsets` header instead. Path modifiers like `/readonly` and `/insiders` can be combined with the `X-MCP-Insiders` or `X-MCP-Readonly` headers.
Example:
diff --git a/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/streamable-http.md b/docs/streamable-http.md
new file mode 100644
index 000000000..0a11c5ea7
--- /dev/null
+++ b/docs/streamable-http.md
@@ -0,0 +1,93 @@
+# Streamable HTTP Server
+
+The Streamable HTTP mode enables the GitHub MCP Server to run as an HTTP service, allowing clients to connect via standard HTTP protocols. This mode is ideal for deployment scenarios where stdio transport isn't suitable, such as reverse proxy setups, containerized environments, or distributed architectures.
+
+## Features
+
+- **Streamable HTTP Transport** — Full HTTP server with streaming support for real-time tool responses
+- **OAuth Metadata Endpoints** — Standard `.well-known/oauth-protected-resource` discovery for OAuth clients
+- **Scope Challenge Support** — Automatic scope validation with proper HTTP 403 responses and `WWW-Authenticate` headers
+- **Scope Filtering** — Restrict available tools based on authenticated credentials and permissions
+- **Custom Base Paths** — Support for reverse proxy deployments with customizable base URLs
+
+## Running the Server
+
+### Basic HTTP Server
+
+Start the server on the default port (8082):
+
+```bash
+github-mcp-server http
+```
+
+The server will be available at `http://localhost:8082`.
+
+### With Scope Challenge
+
+Enable scope validation to enforce GitHub permission checks:
+
+```bash
+github-mcp-server http --scope-challenge
+```
+
+When `--scope-challenge` is enabled, requests with insufficient scopes receive a `403 Forbidden` response with a `WWW-Authenticate` header indicating the required scopes.
+
+### With OAuth Metadata Discovery
+
+For use behind reverse proxies or with custom domains, expose OAuth metadata endpoints:
+
+```bash
+github-mcp-server http --scope-challenge --base-url https://myserver.com --base-path /mcp
+```
+
+The OAuth protected resource metadata's `resource` attribute will be populated with the full URL to the server's protected resource endpoint:
+
+```json
+{
+ "resource_name": "GitHub MCP Server",
+ "resource": "https://myserver.com/mcp",
+ "authorization_servers": [
+ "https://github.com/login/oauth"
+ ],
+ "scopes_supported": [
+ "repo",
+ ...
+ ],
+ ...
+}
+```
+
+This allows OAuth clients to discover authentication requirements and endpoint information automatically.
+
+## Client Configuration
+
+### Using OAuth Authentication
+
+If your IDE or client has GitHub credentials configured (i.e. VS Code), simply reference the HTTP server:
+
+```json
+{
+ "type": "http",
+ "url": "http://localhost:8082"
+}
+```
+
+The server will use the client's existing GitHub authentication.
+
+### Using Bearer Tokens or Custom Headers
+
+To provide PAT credentials, or to customize server behavior preferences, you can include additional headers in the client configuration:
+
+```json
+{
+ "type": "http",
+ "url": "http://localhost:8082",
+ "headers": {
+ "Authorization": "Bearer ghp_yourtokenhere",
+ "X-MCP-Toolsets": "default",
+ "X-MCP-Readonly": "true"
+ }
+}
+```
+
+See [Remote Server](./remote-server.md) documentation for more details on client configuration options.
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 66d3ff410..050ac9b77 100644
--- a/docs/tool-renaming.md
+++ b/docs/tool-renaming.md
@@ -46,5 +46,29 @@ Will get `issue_read` and `get_file_contents` tools registered, with no errors.
| Old Name | New Name |
|----------|----------|
-| *(none currently)* | |
+| `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` |
+| `list_workflows` | `actions_list` |
+| `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/e2e.test b/e2e.test
deleted file mode 100755
index 58505b3a2..000000000
Binary files a/e2e.test and /dev/null differ
diff --git a/go.mod b/go.mod
index 9423ce557..c6c6e2967 100644
--- a/go.mod
+++ b/go.mod
@@ -4,40 +4,30 @@ go 1.24.0
require (
github.com/google/go-github/v79 v79.0.0
- github.com/google/jsonschema-go v0.3.0
+ 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.1
+ github.com/spf13/cobra v1.10.2
github.com/spf13/viper v1.21.0
github.com/stretchr/testify v1.11.1
)
require (
github.com/aymerick/douceur v0.2.0 // indirect
+ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
+ github.com/fsnotify/fsnotify v1.9.0 // indirect
+ github.com/go-chi/chi/v5 v5.2.3
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/go-viper/mapstructure/v2 v2.5.0
+ github.com/google/go-querystring v1.1.0 // indirect
github.com/gorilla/css v1.0.1 // indirect
- github.com/gorilla/mux v1.8.0 // indirect
+ github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
+ github.com/lithammer/fuzzysearch v1.1.8
github.com/mailru/easyjson v0.7.7 // indirect
- github.com/stretchr/objx v0.5.2 // indirect
- github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 // indirect
- go.yaml.in/yaml/v3 v3.0.4 // indirect
- golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect
- golang.org/x/net v0.38.0 // indirect
- gopkg.in/yaml.v2 v2.4.0 // indirect
-)
-
-require (
- github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
- github.com/fsnotify/fsnotify v1.9.0 // indirect
- github.com/go-viper/mapstructure/v2 v2.4.0
- github.com/google/go-querystring v1.1.0 // indirect
- github.com/inconshreveable/mousetrap v1.1.0 // indirect
- github.com/modelcontextprotocol/go-sdk v1.2.0-pre.1
+ github.com/modelcontextprotocol/go-sdk v1.2.0
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/rogpeppe/go-internal v1.13.1 // indirect
@@ -48,12 +38,17 @@ require (
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10
+ github.com/stretchr/objx v0.5.2 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2
+ github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 // indirect
+ go.yaml.in/yaml/v3 v3.0.4 // indirect
+ golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect
+ golang.org/x/net v0.38.0 // indirect
golang.org/x/oauth2 v0.30.0 // indirect
golang.org/x/sys v0.31.0 // indirect
golang.org/x/text v0.28.0 // indirect
- golang.org/x/time v0.5.0 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
+ gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
diff --git a/go.sum b/go.sum
index fc0980ab1..d525cb0a1 100644
--- a/go.sum
+++ b/go.sum
@@ -10,30 +10,28 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
+github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
+github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY=
github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
github.com/go-openapi/swag v0.21.1 h1:wm0rhTb5z7qpJRHBdPOMuY4QjVUMbF6/kwoYeRAOrKU=
github.com/go-openapi/swag v0.21.1/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
-github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
-github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
+github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
+github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
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=
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
-github.com/google/jsonschema-go v0.3.0 h1:6AH2TxVNtk3IlvkkhjrtbUc4S8AvO0Xii0DxIygDg+Q=
-github.com/google/jsonschema-go v0.3.0/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
+github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
+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=
@@ -48,6 +46,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4=
+github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4=
github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
@@ -55,10 +55,8 @@ 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-pre.1 h1:14+JrlEIFvUmbu5+iJzWPLk8CkpvegfKr42oXyjp3O4=
-github.com/modelcontextprotocol/go-sdk v1.2.0-pre.1/go.mod h1:6fM3LCm3yV7pAs8isnKLn07oKtB0MP9LHd3DfAcKw10=
+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=
github.com/muesli/cache2go v0.0.0-20221011235721-518229cd8021/go.mod h1:WERUkUryfUWlrHnFSO/BEUZ+7Ns8aZy7iVOGewxKzcc=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
@@ -82,8 +80,8 @@ github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
-github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
-github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
+github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
+github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
@@ -102,22 +100,51 @@ github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zI
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 h1:BHyfKlQyqbsFN5p3IfnEUduWvb9is428/nNb5L3U01M=
github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM=
+github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
+golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
+golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
+golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
+golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
+golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
+golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
+golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
+golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
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.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
+golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
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-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
diff --git a/internal/ghmcp/server.go b/internal/ghmcp/server.go
index 4842b2c64..1fd56b7ab 100644
--- a/internal/ghmcp/server.go
+++ b/internal/ghmcp/server.go
@@ -6,7 +6,6 @@ import (
"io"
"log/slog"
"net/http"
- "net/url"
"os"
"os/signal"
"strings"
@@ -15,60 +14,19 @@ import (
"github.com/github/github-mcp-server/pkg/errors"
"github.com/github/github-mcp-server/pkg/github"
+ "github.com/github/github-mcp-server/pkg/http/transport"
"github.com/github/github-mcp-server/pkg/inventory"
"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"
+ "github.com/github/github-mcp-server/pkg/utils"
gogithub "github.com/google/go-github/v79/github"
"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/shurcooL/githubv4"
)
-type MCPServerConfig struct {
- // Version of the server
- Version string
-
- // GitHub Host to target for API requests (e.g. github.com or github.enterprise.com)
- Host string
-
- // GitHub Token to authenticate with the GitHub API
- Token string
-
- // EnabledToolsets is a list of toolsets to enable
- // See: https://github.com/github/github-mcp-server?tab=readme-ov-file#tool-configuration
- EnabledToolsets []string
-
- // EnabledTools is a list of specific tools to enable (additive to toolsets)
- // When specified, these tools are registered in addition to any specified toolset tools
- EnabledTools []string
-
- // EnabledFeatures is a list of feature flags that are enabled
- // Items with FeatureFlagEnable matching an entry in this list will be available
- EnabledFeatures []string
-
- // Whether to enable dynamic toolsets
- // See: https://github.com/github/github-mcp-server?tab=readme-ov-file#dynamic-tool-discovery
- DynamicToolsets bool
-
- // ReadOnly indicates if we should only offer read-only tools
- ReadOnly bool
-
- // Translator provides translated text for the server tooling
- Translator translations.TranslationHelperFunc
-
- // Content window size
- ContentWindowSize int
-
- // LockdownMode indicates if we should enable lockdown mode
- LockdownMode bool
-
- // Logger is used for logging within the server
- Logger *slog.Logger
- // RepoAccessTTL overrides the default TTL for repository access cache entries.
- RepoAccessTTL *time.Duration
-}
-
// githubClients holds all the GitHub API clients created for a server instance.
type githubClients struct {
rest *gogithub.Client
@@ -79,25 +37,48 @@ type githubClients struct {
}
// createGitHubClients creates all the GitHub API clients needed by the server.
-func createGitHubClients(cfg MCPServerConfig, apiHost apiHost) (*githubClients, error) {
+func createGitHubClients(cfg github.MCPServerConfig, apiHost utils.APIHostResolver) (*githubClients, error) {
+ restURL, err := apiHost.BaseRESTURL(context.Background())
+ if err != nil {
+ return nil, fmt.Errorf("failed to get base REST URL: %w", err)
+ }
+
+ uploadURL, err := apiHost.UploadURL(context.Background())
+ if err != nil {
+ return nil, fmt.Errorf("failed to get upload URL: %w", err)
+ }
+
+ graphQLURL, err := apiHost.GraphqlURL(context.Background())
+ if err != nil {
+ return nil, fmt.Errorf("failed to get GraphQL URL: %w", err)
+ }
+
+ rawURL, err := apiHost.RawURL(context.Background())
+ if err != nil {
+ return nil, fmt.Errorf("failed to get Raw URL: %w", err)
+ }
+
// Construct REST client
restClient := gogithub.NewClient(nil).WithAuthToken(cfg.Token)
restClient.UserAgent = fmt.Sprintf("github-mcp-server/%s", cfg.Version)
- restClient.BaseURL = apiHost.baseRESTURL
- restClient.UploadURL = apiHost.uploadURL
+ restClient.BaseURL = restURL
+ restClient.UploadURL = uploadURL
// Construct GraphQL client
// We use NewEnterpriseClient unconditionally since we already parsed the API host
gqlHTTPClient := &http.Client{
- Transport: &bearerAuthTransport{
- transport: http.DefaultTransport,
- token: cfg.Token,
+ Transport: &transport.BearerAuthTransport{
+ Transport: &transport.GraphQLFeaturesTransport{
+ Transport: http.DefaultTransport,
+ },
+ Token: cfg.Token,
},
}
- gqlClient := githubv4.NewEnterpriseClient(apiHost.graphqlURL.String(), gqlHTTPClient)
+
+ gqlClient := githubv4.NewEnterpriseClient(graphQLURL.String(), gqlHTTPClient)
// Create raw content client (shares REST client's HTTP transport)
- rawClient := raw.NewClient(restClient, apiHost.rawURL)
+ rawClient := raw.NewClient(restClient, rawURL)
// Set up repo access cache for lockdown mode
var repoAccessCache *lockdown.RepoAccessCache
@@ -120,35 +101,8 @@ func createGitHubClients(cfg MCPServerConfig, apiHost apiHost) (*githubClients,
}, nil
}
-// resolveEnabledToolsets determines which toolsets should be enabled based on config.
-// Returns nil for "use defaults", empty slice for "none", or explicit list.
-func resolveEnabledToolsets(cfg MCPServerConfig) []string {
- enabledToolsets := cfg.EnabledToolsets
-
- // In dynamic mode, remove "all" and "default" since users enable toolsets on demand
- if cfg.DynamicToolsets && enabledToolsets != nil {
- enabledToolsets = github.RemoveToolset(enabledToolsets, string(github.ToolsetMetadataAll.ID))
- enabledToolsets = github.RemoveToolset(enabledToolsets, string(github.ToolsetMetadataDefault.ID))
- }
-
- if enabledToolsets != nil {
- return enabledToolsets
- }
- if cfg.DynamicToolsets {
- // Dynamic mode with no toolsets specified: start empty so users enable on demand
- return []string{}
- }
- if len(cfg.EnabledTools) > 0 {
- // When specific tools are requested but no toolsets, don't use default toolsets
- // This matches the original behavior: --tools=X alone registers only X
- return []string{}
- }
- // nil means "use defaults" in WithToolsets
- return nil
-}
-
-func NewMCPServer(cfg MCPServerConfig) (*mcp.Server, error) {
- apiHost, err := parseAPIHost(cfg.Host)
+func NewStdioMCPServer(ctx context.Context, cfg github.MCPServerConfig) (*mcp.Server, error) {
+ apiHost, err := utils.NewAPIHost(cfg.Host)
if err != nil {
return nil, fmt.Errorf("failed to parse API host: %w", err)
}
@@ -158,39 +112,8 @@ func NewMCPServer(cfg MCPServerConfig) (*mcp.Server, error) {
return nil, fmt.Errorf("failed to create GitHub clients: %w", err)
}
- enabledToolsets := resolveEnabledToolsets(cfg)
-
- // For instruction generation, we need actual toolset names (not nil).
- // nil means "use defaults" in inventory, so expand it for instructions.
- instructionToolsets := enabledToolsets
- if instructionToolsets == nil {
- instructionToolsets = github.GetDefaultToolsetIDs()
- }
-
- // Create the MCP server
- serverOpts := &mcp.ServerOptions{
- Instructions: github.GenerateInstructions(instructionToolsets),
- Logger: cfg.Logger,
- CompletionHandler: github.CompletionsHandler(func(_ context.Context) (*gogithub.Client, error) {
- return clients.rest, nil
- }),
- }
-
- // In dynamic mode, explicitly advertise capabilities since tools/resources/prompts
- // may be enabled at runtime even if none are registered initially.
- if cfg.DynamicToolsets {
- serverOpts.Capabilities = &mcp.ServerCapabilities{
- Tools: &mcp.ToolCapabilities{},
- Resources: &mcp.ResourceCapabilities{},
- Prompts: &mcp.PromptCapabilities{},
- }
- }
-
- ghServer := github.NewServer(cfg.Version, serverOpts)
-
- // Add middlewares
- ghServer.AddReceivingMiddleware(addGitHubAPIErrorToContext)
- ghServer.AddReceivingMiddleware(addUserAgentsMiddleware(cfg, clients.rest, clients.gqlHTTP))
+ // Create feature checker
+ featureChecker := createFeatureChecker(cfg.EnabledFeatures)
// Create dependencies for tool handlers
deps := github.NewBaseDeps(
@@ -199,63 +122,40 @@ func NewMCPServer(cfg MCPServerConfig) (*mcp.Server, error) {
clients.raw,
clients.repoAccess,
cfg.Translator,
- github.FeatureFlags{LockdownMode: cfg.LockdownMode},
+ github.FeatureFlags{
+ LockdownMode: cfg.LockdownMode,
+ InsidersMode: cfg.InsidersMode,
+ },
cfg.ContentWindowSize,
+ featureChecker,
)
-
// 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).
+ WithToolsets(github.ResolvedEnabledToolsets(cfg.DynamicToolsets, cfg.EnabledToolsets, cfg.EnabledTools)).
WithTools(github.CleanTools(cfg.EnabledTools)).
- WithFeatureChecker(createFeatureChecker(cfg.EnabledFeatures)).
- Build()
+ WithServerInstructions().
+ WithFeatureChecker(featureChecker)
- if unrecognized := inventory.UnrecognizedToolsets(); len(unrecognized) > 0 {
- fmt.Fprintf(os.Stderr, "Warning: unrecognized toolsets ignored: %s\n", strings.Join(unrecognized, ", "))
+ // Apply token scope filtering if scopes are known (for PAT filtering)
+ if cfg.TokenScopes != nil {
+ inventoryBuilder = inventoryBuilder.WithFilter(github.CreateToolScopeFilter(cfg.TokenScopes))
}
- // Register GitHub tools/resources/prompts from the inventory.
- // In dynamic mode with no explicit toolsets, this is a no-op since enabledToolsets
- // is empty - users enable toolsets at runtime via the dynamic tools below (but can
- // enable toolsets or tools explicitly that do need registration).
- inventory.RegisterAll(context.Background(), ghServer, deps)
-
- // Register dynamic toolset management tools (enable/disable) - these are separate
- // meta-tools that control the inventory, not part of the inventory itself
- if cfg.DynamicToolsets {
- registerDynamicTools(ghServer, inventory, deps, cfg.Translator)
+ inventory, err := inventoryBuilder.Build()
+ if err != nil {
+ return nil, fmt.Errorf("failed to build inventory: %w", err)
}
- return ghServer, nil
-}
-
-// registerDynamicTools adds the dynamic toolset enable/disable tools to the server.
-func registerDynamicTools(server *mcp.Server, inventory *inventory.Inventory, deps *github.BaseDeps, t translations.TranslationHelperFunc) {
- dynamicDeps := github.DynamicToolDependencies{
- Server: server,
- Inventory: inventory,
- ToolDeps: deps,
- T: t,
- }
- for _, tool := range github.DynamicTools(inventory) {
- tool.RegisterFunc(server, dynamicDeps)
+ ghServer, err := github.NewMCPServer(ctx, &cfg, deps, inventory)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create GitHub MCP server: %w", err)
}
-}
-// createFeatureChecker returns a FeatureFlagChecker that checks if a flag name
-// is present in the provided list of enabled features. For the local server,
-// this is populated from the --features CLI flag.
-func createFeatureChecker(enabledFeatures []string) inventory.FeatureFlagChecker {
- // Build a set for O(1) lookup
- featureSet := make(map[string]bool, len(enabledFeatures))
- for _, f := range enabledFeatures {
- featureSet[f] = true
- }
- return func(_ context.Context, flagName string) (bool, error) {
- return featureSet[flagName], nil
- }
+ ghServer.AddReceivingMiddleware(addUserAgentsMiddleware(cfg, clients.rest, clients.gqlHTTP))
+
+ return ghServer, nil
}
type StdioServerConfig struct {
@@ -303,6 +203,9 @@ type StdioServerConfig struct {
// LockdownMode indicates if we should enable lockdown mode
LockdownMode bool
+ // InsidersMode indicates if we should enable experimental features
+ InsidersMode bool
+
// RepoAccessCacheTTL overrides the default TTL for repository access cache entries.
RepoAccessCacheTTL *time.Duration
}
@@ -331,7 +234,23 @@ 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)
- ghServer, err := NewMCPServer(MCPServerConfig{
+ // 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 := NewStdioMCPServer(ctx, github.MCPServerConfig{
Version: cfg.Version,
Host: cfg.Host,
Token: cfg.Token,
@@ -343,8 +262,10 @@ func RunStdioServer(cfg StdioServerConfig) error {
Translator: t,
ContentWindowSize: cfg.ContentWindowSize,
LockdownMode: cfg.LockdownMode,
+ InsidersMode: cfg.InsidersMode,
Logger: logger,
RepoAccessTTL: cfg.RepoAccessCacheTTL,
+ TokenScopes: tokenScopes,
})
if err != nil {
return fmt.Errorf("failed to create MCP server: %w", err)
@@ -391,214 +312,21 @@ func RunStdioServer(cfg StdioServerConfig) error {
return nil
}
-type apiHost struct {
- baseRESTURL *url.URL
- graphqlURL *url.URL
- uploadURL *url.URL
- rawURL *url.URL
-}
-
-func newDotcomHost() (apiHost, error) {
- baseRestURL, err := url.Parse("https://api.github.com/")
- if err != nil {
- return apiHost{}, fmt.Errorf("failed to parse dotcom REST URL: %w", err)
- }
-
- gqlURL, err := url.Parse("https://api.github.com/graphql")
- if err != nil {
- return apiHost{}, fmt.Errorf("failed to parse dotcom GraphQL URL: %w", err)
- }
-
- uploadURL, err := url.Parse("https://uploads.github.com")
- if err != nil {
- return apiHost{}, fmt.Errorf("failed to parse dotcom Upload URL: %w", err)
- }
-
- rawURL, err := url.Parse("https://raw.githubusercontent.com/")
- if err != nil {
- return apiHost{}, fmt.Errorf("failed to parse dotcom Raw URL: %w", err)
- }
-
- return apiHost{
- baseRESTURL: baseRestURL,
- graphqlURL: gqlURL,
- uploadURL: uploadURL,
- rawURL: rawURL,
- }, nil
-}
-
-func newGHECHost(hostname string) (apiHost, error) {
- u, err := url.Parse(hostname)
- if err != nil {
- return apiHost{}, fmt.Errorf("failed to parse GHEC URL: %w", err)
- }
-
- // Unsecured GHEC would be an error
- if u.Scheme == "http" {
- return apiHost{}, fmt.Errorf("GHEC URL must be HTTPS")
- }
-
- restURL, err := url.Parse(fmt.Sprintf("https://api.%s/", u.Hostname()))
- if err != nil {
- return apiHost{}, fmt.Errorf("failed to parse GHEC REST URL: %w", err)
- }
-
- gqlURL, err := url.Parse(fmt.Sprintf("https://api.%s/graphql", u.Hostname()))
- if err != nil {
- return apiHost{}, fmt.Errorf("failed to parse GHEC GraphQL URL: %w", err)
- }
-
- uploadURL, err := url.Parse(fmt.Sprintf("https://uploads.%s", u.Hostname()))
- if err != nil {
- return apiHost{}, fmt.Errorf("failed to parse GHEC Upload URL: %w", err)
- }
-
- rawURL, err := url.Parse(fmt.Sprintf("https://raw.%s/", u.Hostname()))
- if err != nil {
- return apiHost{}, fmt.Errorf("failed to parse GHEC Raw URL: %w", err)
- }
-
- return apiHost{
- baseRESTURL: restURL,
- graphqlURL: gqlURL,
- uploadURL: uploadURL,
- rawURL: rawURL,
- }, nil
-}
-
-func newGHESHost(hostname string) (apiHost, error) {
- u, err := url.Parse(hostname)
- if err != nil {
- return apiHost{}, fmt.Errorf("failed to parse GHES URL: %w", err)
- }
-
- restURL, err := url.Parse(fmt.Sprintf("%s://%s/api/v3/", u.Scheme, u.Hostname()))
- if err != nil {
- return apiHost{}, fmt.Errorf("failed to parse GHES REST URL: %w", err)
- }
-
- gqlURL, err := url.Parse(fmt.Sprintf("%s://%s/api/graphql", u.Scheme, u.Hostname()))
- if err != nil {
- return apiHost{}, fmt.Errorf("failed to parse GHES GraphQL URL: %w", err)
- }
-
- // Check if subdomain isolation is enabled
- // See https://docs.github.com/en/enterprise-server@3.17/admin/configuring-settings/hardening-security-for-your-enterprise/enabling-subdomain-isolation#about-subdomain-isolation
- hasSubdomainIsolation := checkSubdomainIsolation(u.Scheme, u.Hostname())
-
- var uploadURL *url.URL
- if hasSubdomainIsolation {
- // With subdomain isolation: https://uploads.hostname/
- uploadURL, err = url.Parse(fmt.Sprintf("%s://uploads.%s/", u.Scheme, u.Hostname()))
- } else {
- // Without subdomain isolation: https://hostname/api/uploads/
- uploadURL, err = url.Parse(fmt.Sprintf("%s://%s/api/uploads/", u.Scheme, u.Hostname()))
- }
- if err != nil {
- return apiHost{}, fmt.Errorf("failed to parse GHES Upload URL: %w", err)
- }
-
- var rawURL *url.URL
- if hasSubdomainIsolation {
- // With subdomain isolation: https://raw.hostname/
- rawURL, err = url.Parse(fmt.Sprintf("%s://raw.%s/", u.Scheme, u.Hostname()))
- } else {
- // Without subdomain isolation: https://hostname/raw/
- rawURL, err = url.Parse(fmt.Sprintf("%s://%s/raw/", u.Scheme, u.Hostname()))
- }
- if err != nil {
- return apiHost{}, fmt.Errorf("failed to parse GHES Raw URL: %w", err)
- }
-
- return apiHost{
- baseRESTURL: restURL,
- graphqlURL: gqlURL,
- uploadURL: uploadURL,
- rawURL: rawURL,
- }, nil
-}
-
-// checkSubdomainIsolation detects if GitHub Enterprise Server has subdomain isolation enabled
-// by attempting to ping the raw./_ping endpoint on the subdomain. The raw subdomain must always exist for subdomain isolation.
-func checkSubdomainIsolation(scheme, hostname string) bool {
- subdomainURL := fmt.Sprintf("%s://raw.%s/_ping", scheme, hostname)
-
- client := &http.Client{
- Timeout: 5 * time.Second,
- // Don't follow redirects - we just want to check if the endpoint exists
- //nolint:revive // parameters are required by http.Client.CheckRedirect signature
- CheckRedirect: func(req *http.Request, via []*http.Request) error {
- return http.ErrUseLastResponse
- },
- }
-
- resp, err := client.Get(subdomainURL)
- if err != nil {
- return false
- }
- defer resp.Body.Close()
-
- return resp.StatusCode == http.StatusOK
-}
-
-// Note that this does not handle ports yet, so development environments are out.
-func parseAPIHost(s string) (apiHost, error) {
- if s == "" {
- return newDotcomHost()
- }
-
- u, err := url.Parse(s)
- if err != nil {
- return apiHost{}, fmt.Errorf("could not parse host as URL: %s", s)
- }
-
- if u.Scheme == "" {
- return apiHost{}, fmt.Errorf("host must have a scheme (http or https): %s", s)
- }
-
- if strings.HasSuffix(u.Hostname(), "github.com") {
- return newDotcomHost()
- }
-
- if strings.HasSuffix(u.Hostname(), "ghe.com") {
- return newGHECHost(s)
+// createFeatureChecker returns a FeatureFlagChecker that checks if a flag name
+// is present in the provided list of enabled features. For the local server,
+// this is populated from the --features CLI flag.
+func createFeatureChecker(enabledFeatures []string) inventory.FeatureFlagChecker {
+ // Build a set for O(1) lookup
+ featureSet := make(map[string]bool, len(enabledFeatures))
+ for _, f := range enabledFeatures {
+ featureSet[f] = true
}
-
- return newGHESHost(s)
-}
-
-type userAgentTransport struct {
- transport http.RoundTripper
- agent string
-}
-
-func (t *userAgentTransport) RoundTrip(req *http.Request) (*http.Response, error) {
- req = req.Clone(req.Context())
- req.Header.Set("User-Agent", t.agent)
- return t.transport.RoundTrip(req)
-}
-
-type bearerAuthTransport struct {
- transport http.RoundTripper
- token string
-}
-
-func (t *bearerAuthTransport) RoundTrip(req *http.Request) (*http.Response, error) {
- req = req.Clone(req.Context())
- req.Header.Set("Authorization", "Bearer "+t.token)
- return t.transport.RoundTrip(req)
-}
-
-func addGitHubAPIErrorToContext(next mcp.MethodHandler) mcp.MethodHandler {
- return func(ctx context.Context, method string, req mcp.Request) (result mcp.Result, err error) {
- // Ensure the context is cleared of any previous errors
- // as context isn't propagated through middleware
- ctx = errors.ContextWithGitHubErrors(ctx)
- return next(ctx, method, req)
+ return func(_ context.Context, flagName string) (bool, error) {
+ return featureSet[flagName], nil
}
}
-func addUserAgentsMiddleware(cfg MCPServerConfig, restClient *gogithub.Client, gqlHTTPClient *http.Client) func(next mcp.MethodHandler) mcp.MethodHandler {
+func addUserAgentsMiddleware(cfg github.MCPServerConfig, restClient *gogithub.Client, gqlHTTPClient *http.Client) func(next mcp.MethodHandler) mcp.MethodHandler {
return func(next mcp.MethodHandler) mcp.MethodHandler {
return func(ctx context.Context, method string, request mcp.Request) (result mcp.Result, err error) {
if method != "initialize" {
@@ -620,12 +348,25 @@ func addUserAgentsMiddleware(cfg MCPServerConfig, restClient *gogithub.Client, g
restClient.UserAgent = userAgent
- gqlHTTPClient.Transport = &userAgentTransport{
- transport: gqlHTTPClient.Transport,
- agent: userAgent,
+ gqlHTTPClient.Transport = &transport.UserAgentTransport{
+ Transport: gqlHTTPClient.Transport,
+ Agent: userAgent,
}
return next(ctx, method, request)
}
}
}
+
+// 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 := utils.NewAPIHost(host)
+ if err != nil {
+ return nil, fmt.Errorf("failed to parse API host: %w", err)
+ }
+
+ fetcher := scopes.NewFetcher(apiHost, scopes.FetcherOptions{})
+
+ return fetcher.FetchTokenScopes(ctx, token)
+}
diff --git a/internal/ghmcp/server_test.go b/internal/ghmcp/server_test.go
new file mode 100644
index 000000000..6f0e3ac3f
--- /dev/null
+++ b/internal/ghmcp/server_test.go
@@ -0,0 +1 @@
+package ghmcp
diff --git a/internal/toolsnaps/toolsnaps.go b/internal/toolsnaps/toolsnaps.go
index 89d02e1ee..4b25904ed 100644
--- a/internal/toolsnaps/toolsnaps.go
+++ b/internal/toolsnaps/toolsnaps.go
@@ -67,15 +67,35 @@ func Test(toolName string, tool any) error {
}
func writeSnap(snapPath string, contents []byte) error {
+ // Sort the JSON keys recursively to ensure consistent output.
+ // We do this by unmarshaling and remarshaling, which ensures Go's JSON encoder
+ // sorts all map keys alphabetically at every level.
+ sortedJSON, err := sortJSONKeys(contents)
+ if err != nil {
+ return fmt.Errorf("failed to sort JSON keys: %w", err)
+ }
+
// Ensure the directory exists
if err := os.MkdirAll(filepath.Dir(snapPath), 0700); err != nil {
return fmt.Errorf("failed to create snapshot directory: %w", err)
}
// Write the snapshot file
- if err := os.WriteFile(snapPath, contents, 0600); err != nil {
+ if err := os.WriteFile(snapPath, sortedJSON, 0600); err != nil {
return fmt.Errorf("failed to write snapshot file: %w", err)
}
return nil
}
+
+// sortJSONKeys recursively sorts all object keys in a JSON byte array by
+// unmarshaling to map[string]any and remarshaling. Go's JSON encoder
+// automatically sorts map keys alphabetically.
+func sortJSONKeys(jsonData []byte) ([]byte, error) {
+ var data any
+ if err := json.Unmarshal(jsonData, &data); err != nil {
+ return nil, err
+ }
+
+ return json.MarshalIndent(data, "", " ")
+}
diff --git a/internal/toolsnaps/toolsnaps_test.go b/internal/toolsnaps/toolsnaps_test.go
index be9cadf7f..c7d7301bc 100644
--- a/internal/toolsnaps/toolsnaps_test.go
+++ b/internal/toolsnaps/toolsnaps_test.go
@@ -131,3 +131,184 @@ func TestMalformedSnapshotJSON(t *testing.T) {
require.Error(t, err)
assert.Contains(t, err.Error(), "failed to parse snapshot JSON for dummy", "expected error about malformed snapshot JSON")
}
+
+func TestSortJSONKeys(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ expected string
+ }{
+ {
+ name: "simple object",
+ input: `{"z": 1, "a": 2, "m": 3}`,
+ expected: "{\n \"a\": 2,\n \"m\": 3,\n \"z\": 1\n}",
+ },
+ {
+ name: "nested object",
+ input: `{"z": {"y": 1, "x": 2}, "a": 3}`,
+ expected: "{\n \"a\": 3,\n \"z\": {\n \"x\": 2,\n \"y\": 1\n }\n}",
+ },
+ {
+ name: "array with objects",
+ input: `{"items": [{"z": 1, "a": 2}, {"y": 3, "b": 4}]}`,
+ expected: "{\n \"items\": [\n {\n \"a\": 2,\n \"z\": 1\n },\n {\n \"b\": 4,\n \"y\": 3\n }\n ]\n}",
+ },
+ {
+ name: "deeply nested",
+ input: `{"z": {"y": {"x": 1, "a": 2}, "b": 3}, "m": 4}`,
+ expected: "{\n \"m\": 4,\n \"z\": {\n \"b\": 3,\n \"y\": {\n \"a\": 2,\n \"x\": 1\n }\n }\n}",
+ },
+ {
+ name: "properties field like in toolsnaps",
+ input: `{"name": "test", "properties": {"repo": {"type": "string"}, "owner": {"type": "string"}, "page": {"type": "number"}}}`,
+ expected: "{\n \"name\": \"test\",\n \"properties\": {\n \"owner\": {\n \"type\": \"string\"\n },\n \"page\": {\n \"type\": \"number\"\n },\n \"repo\": {\n \"type\": \"string\"\n }\n }\n}",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result, err := sortJSONKeys([]byte(tt.input))
+ require.NoError(t, err)
+ assert.Equal(t, tt.expected, string(result))
+ })
+ }
+}
+
+func TestSortJSONKeysIdempotent(t *testing.T) {
+ // Given a JSON string that's already sorted
+ input := `{"a": 1, "b": {"x": 2, "y": 3}, "c": [{"m": 4, "n": 5}]}`
+
+ // When we sort it once
+ sorted1, err := sortJSONKeys([]byte(input))
+ require.NoError(t, err)
+
+ // And sort it again
+ sorted2, err := sortJSONKeys(sorted1)
+ require.NoError(t, err)
+
+ // Then the results should be identical
+ assert.Equal(t, string(sorted1), string(sorted2))
+}
+
+func TestToolSnapKeysSorted(t *testing.T) {
+ withIsolatedWorkingDir(t)
+
+ // Given a tool with fields that could be in any order
+ type complexTool struct {
+ Name string `json:"name"`
+ Description string `json:"description"`
+ Properties map[string]interface{} `json:"properties"`
+ Annotations map[string]interface{} `json:"annotations"`
+ }
+
+ tool := complexTool{
+ Name: "test_tool",
+ Description: "A test tool",
+ Properties: map[string]interface{}{
+ "zzz": "last",
+ "aaa": "first",
+ "mmm": "middle",
+ "owner": map[string]interface{}{"type": "string", "description": "Owner"},
+ "repo": map[string]interface{}{"type": "string", "description": "Repo"},
+ },
+ Annotations: map[string]interface{}{
+ "readOnly": true,
+ "title": "Test",
+ },
+ }
+
+ // When we write the snapshot
+ t.Setenv("UPDATE_TOOLSNAPS", "true")
+ err := Test("complex", tool)
+ require.NoError(t, err)
+
+ // Then the snapshot file should have sorted keys
+ snapJSON, err := os.ReadFile("__toolsnaps__/complex.snap")
+ require.NoError(t, err)
+
+ // Verify that the JSON is properly sorted by checking key order
+ var parsed map[string]interface{}
+ err = json.Unmarshal(snapJSON, &parsed)
+ require.NoError(t, err)
+
+ // Check that properties are sorted
+ propsJSON, _ := json.MarshalIndent(parsed["properties"], "", " ")
+ propsStr := string(propsJSON)
+ // The properties should have "aaa" before "mmm" before "zzz"
+ aaaIndex := -1
+ mmmIndex := -1
+ zzzIndex := -1
+ for i, line := range propsStr {
+ if line == 'a' && i+2 < len(propsStr) && propsStr[i:i+3] == "aaa" {
+ aaaIndex = i
+ }
+ if line == 'm' && i+2 < len(propsStr) && propsStr[i:i+3] == "mmm" {
+ mmmIndex = i
+ }
+ if line == 'z' && i+2 < len(propsStr) && propsStr[i:i+3] == "zzz" {
+ zzzIndex = i
+ }
+ }
+ assert.Greater(t, mmmIndex, aaaIndex, "mmm should come after aaa")
+ assert.Greater(t, zzzIndex, mmmIndex, "zzz should come after mmm")
+}
+
+func TestStructFieldOrderingSortedAlphabetically(t *testing.T) {
+ withIsolatedWorkingDir(t)
+
+ // Given a struct with fields defined in non-alphabetical order
+ // This test ensures that struct field order doesn't affect the JSON output
+ type toolWithNonAlphabeticalFields struct {
+ ZField string `json:"zField"` // Should appear last in JSON
+ AField string `json:"aField"` // Should appear first in JSON
+ MField string `json:"mField"` // Should appear in the middle
+ }
+
+ tool := toolWithNonAlphabeticalFields{
+ ZField: "z value",
+ AField: "a value",
+ MField: "m value",
+ }
+
+ // When we write the snapshot
+ t.Setenv("UPDATE_TOOLSNAPS", "true")
+ err := Test("struct_field_order", tool)
+ require.NoError(t, err)
+
+ // Then the snapshot file should have alphabetically sorted keys despite struct field order
+ snapJSON, err := os.ReadFile("__toolsnaps__/struct_field_order.snap")
+ require.NoError(t, err)
+
+ snapStr := string(snapJSON)
+
+ // Find the positions of each field in the JSON string
+ aFieldIndex := -1
+ mFieldIndex := -1
+ zFieldIndex := -1
+ for i := 0; i < len(snapStr)-7; i++ {
+ switch snapStr[i : i+6] {
+ case "aField":
+ aFieldIndex = i
+ case "mField":
+ mFieldIndex = i
+ case "zField":
+ zFieldIndex = i
+ }
+ }
+
+ // Verify alphabetical ordering in the JSON output
+ require.NotEqual(t, -1, aFieldIndex, "aField should be present")
+ require.NotEqual(t, -1, mFieldIndex, "mField should be present")
+ require.NotEqual(t, -1, zFieldIndex, "zField should be present")
+ assert.Less(t, aFieldIndex, mFieldIndex, "aField should appear before mField")
+ assert.Less(t, mFieldIndex, zFieldIndex, "mField should appear before zField")
+
+ // Also verify idempotency - running the test again should produce identical output
+ err = Test("struct_field_order", tool)
+ require.NoError(t, err)
+
+ snapJSON2, err := os.ReadFile("__toolsnaps__/struct_field_order.snap")
+ require.NoError(t, err)
+
+ assert.Equal(t, string(snapJSON), string(snapJSON2), "Multiple runs should produce identical output")
+}
diff --git a/pkg/buffer/buffer.go b/pkg/buffer/buffer.go
index 546b5324c..54ed0be4d 100644
--- a/pkg/buffer/buffer.go
+++ b/pkg/buffer/buffer.go
@@ -1,12 +1,17 @@
package buffer
import (
- "bufio"
+ "bytes"
"fmt"
+ "io"
"net/http"
"strings"
)
+// maxLineSize is the maximum size for a single log line (10MB).
+// GitHub Actions logs can contain extremely long lines (base64 content, minified JS, etc.)
+const maxLineSize = 10 * 1024 * 1024
+
// ProcessResponseAsRingBufferToEnd reads the body of an HTTP response line by line,
// storing only the last maxJobLogLines lines using a ring buffer (sliding window).
// This efficiently retains the most recent lines, overwriting older ones as needed.
@@ -25,26 +30,85 @@ import (
//
// The function uses a ring buffer to efficiently store only the last maxJobLogLines lines.
// If the response contains more lines than maxJobLogLines, only the most recent lines are kept.
+// Lines exceeding maxLineSize are truncated with a marker.
func ProcessResponseAsRingBufferToEnd(httpResp *http.Response, maxJobLogLines int) (string, int, *http.Response, error) {
+ if maxJobLogLines > 100000 {
+ maxJobLogLines = 100000
+ }
+
lines := make([]string, maxJobLogLines)
validLines := make([]bool, maxJobLogLines)
totalLines := 0
writeIndex := 0
- scanner := bufio.NewScanner(httpResp.Body)
- scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
+ const readBufferSize = 64 * 1024 // 64KB read buffer
+ const maxDisplayLength = 1000 // Keep first 1000 chars of truncated lines
- for scanner.Scan() {
- line := scanner.Text()
- totalLines++
+ readBuf := make([]byte, readBufferSize)
+ var currentLine strings.Builder
+ lineTruncated := false
+ // storeLine saves the current line to the ring buffer and resets state
+ storeLine := func() {
+ line := currentLine.String()
+ if lineTruncated && len(line) > maxDisplayLength {
+ line = line[:maxDisplayLength]
+ }
+ if lineTruncated {
+ line += "... [TRUNCATED]"
+ }
lines[writeIndex] = line
validLines[writeIndex] = true
+ totalLines++
writeIndex = (writeIndex + 1) % maxJobLogLines
+ currentLine.Reset()
+ lineTruncated = false
}
- if err := scanner.Err(); err != nil {
- return "", 0, httpResp, fmt.Errorf("failed to read log content: %w", err)
+ // accumulate adds bytes to currentLine up to maxLineSize, sets lineTruncated if exceeded
+ accumulate := func(data []byte) {
+ if lineTruncated {
+ return
+ }
+ remaining := maxLineSize - currentLine.Len()
+ if remaining <= 0 {
+ lineTruncated = true
+ return
+ }
+ if remaining > len(data) {
+ remaining = len(data)
+ }
+ currentLine.Write(data[:remaining])
+ if currentLine.Len() >= maxLineSize {
+ lineTruncated = true
+ }
+ }
+
+ for {
+ n, err := httpResp.Body.Read(readBuf)
+ if n > 0 {
+ chunk := readBuf[:n]
+ for len(chunk) > 0 {
+ newlineIdx := bytes.IndexByte(chunk, '\n')
+ if newlineIdx < 0 {
+ accumulate(chunk)
+ break
+ }
+ accumulate(chunk[:newlineIdx])
+ storeLine()
+ chunk = chunk[newlineIdx+1:]
+ }
+ }
+
+ if err == io.EOF {
+ if currentLine.Len() > 0 {
+ storeLine()
+ }
+ break
+ }
+ if err != nil {
+ return "", 0, httpResp, fmt.Errorf("failed to read log content: %w", err)
+ }
}
var result []string
diff --git a/pkg/buffer/buffer_test.go b/pkg/buffer/buffer_test.go
new file mode 100644
index 000000000..86308ec5e
--- /dev/null
+++ b/pkg/buffer/buffer_test.go
@@ -0,0 +1,176 @@
+package buffer
+
+import (
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestProcessResponseAsRingBufferToEnd(t *testing.T) {
+ t.Run("normal lines", func(t *testing.T) {
+ body := "line1\nline2\nline3\n"
+ resp := &http.Response{
+ Body: io.NopCloser(strings.NewReader(body)),
+ }
+
+ result, totalLines, respOut, err := ProcessResponseAsRingBufferToEnd(resp, 10)
+ if respOut != nil && respOut.Body != nil {
+ defer respOut.Body.Close()
+ }
+ require.NoError(t, err)
+ assert.Equal(t, 3, totalLines)
+ assert.Equal(t, "line1\nline2\nline3", result)
+ })
+
+ t.Run("ring buffer keeps last N lines", func(t *testing.T) {
+ body := "line1\nline2\nline3\nline4\nline5\n"
+ resp := &http.Response{
+ Body: io.NopCloser(strings.NewReader(body)),
+ }
+
+ result, totalLines, respOut, err := ProcessResponseAsRingBufferToEnd(resp, 3)
+ if respOut != nil && respOut.Body != nil {
+ defer respOut.Body.Close()
+ }
+ require.NoError(t, err)
+ assert.Equal(t, 5, totalLines)
+ assert.Equal(t, "line3\nline4\nline5", result)
+ })
+
+ t.Run("handles very long line exceeding 10MB", func(t *testing.T) {
+ // Create a line that exceeds maxLineSize (10MB)
+ longLine := strings.Repeat("x", 11*1024*1024) // 11MB
+ body := "line1\n" + longLine + "\nline3\n"
+ resp := &http.Response{
+ Body: io.NopCloser(strings.NewReader(body)),
+ }
+
+ result, totalLines, respOut, err := ProcessResponseAsRingBufferToEnd(resp, 100)
+ if respOut != nil && respOut.Body != nil {
+ defer respOut.Body.Close()
+ }
+ require.NoError(t, err)
+ // Should have processed lines with truncation marker
+ assert.Greater(t, totalLines, 0)
+ assert.Contains(t, result, "TRUNCATED")
+ })
+
+ t.Run("handles line at exactly max size", func(t *testing.T) {
+ // Create a line just under maxLineSize
+ longLine := strings.Repeat("a", 1024*1024) // 1MB - should work fine
+ body := "start\n" + longLine + "\nend\n"
+ resp := &http.Response{
+ Body: io.NopCloser(strings.NewReader(body)),
+ }
+
+ result, totalLines, respOut, err := ProcessResponseAsRingBufferToEnd(resp, 100)
+ if respOut != nil && respOut.Body != nil {
+ defer respOut.Body.Close()
+ }
+ require.NoError(t, err)
+ assert.Equal(t, 3, totalLines)
+ assert.Contains(t, result, "start")
+ assert.Contains(t, result, "end")
+ })
+
+ t.Run("ring buffer with long line in middle of many lines", func(t *testing.T) {
+ // Create many lines with a long line in the middle
+ // Ring buffer size is 5, so we should only keep the last 5 lines
+ var sb strings.Builder
+ for i := 1; i <= 10; i++ {
+ sb.WriteString(fmt.Sprintf("line%d\n", i))
+ }
+ // Insert an 11MB line (exceeds maxLineSize of 10MB)
+ longLine := strings.Repeat("x", 11*1024*1024)
+ sb.WriteString(longLine)
+ sb.WriteString("\n")
+ for i := 11; i <= 20; i++ {
+ sb.WriteString(fmt.Sprintf("line%d\n", i))
+ }
+
+ resp := &http.Response{
+ Body: io.NopCloser(strings.NewReader(sb.String())),
+ }
+
+ result, totalLines, respOut, err := ProcessResponseAsRingBufferToEnd(resp, 5)
+ if respOut != nil && respOut.Body != nil {
+ defer respOut.Body.Close()
+ }
+ require.NoError(t, err)
+ // 10 lines before + 1 long line + 10 lines after = 21 total
+ assert.Equal(t, 21, totalLines)
+ // Should only have the last 5 lines (line16 through line20)
+ assert.Contains(t, result, "line16")
+ assert.Contains(t, result, "line17")
+ assert.Contains(t, result, "line18")
+ assert.Contains(t, result, "line19")
+ assert.Contains(t, result, "line20")
+ // Should NOT contain earlier lines
+ assert.NotContains(t, result, "line1\n")
+ assert.NotContains(t, result, "line10\n")
+ // The truncated line should not be in the last 5
+ assert.NotContains(t, result, "TRUNCATED")
+ })
+
+ t.Run("empty response body", func(t *testing.T) {
+ resp := &http.Response{
+ Body: io.NopCloser(strings.NewReader("")),
+ }
+
+ result, totalLines, respOut, err := ProcessResponseAsRingBufferToEnd(resp, 10)
+ if respOut != nil && respOut.Body != nil {
+ defer respOut.Body.Close()
+ }
+ require.NoError(t, err)
+ assert.Equal(t, 0, totalLines)
+ assert.Equal(t, "", result)
+ })
+
+ t.Run("line at exactly maxLineSize boundary", func(t *testing.T) {
+ // Create a line at exactly maxLineSize (10MB) - should be truncated
+ exactLine := strings.Repeat("z", 10*1024*1024)
+ body := "before\n" + exactLine + "\nafter\n"
+ resp := &http.Response{
+ Body: io.NopCloser(strings.NewReader(body)),
+ }
+
+ result, totalLines, respOut, err := ProcessResponseAsRingBufferToEnd(resp, 10)
+ if respOut != nil && respOut.Body != nil {
+ defer respOut.Body.Close()
+ }
+ require.NoError(t, err)
+ assert.Equal(t, 3, totalLines)
+ assert.Contains(t, result, "before")
+ assert.Contains(t, result, "TRUNCATED")
+ assert.Contains(t, result, "after")
+ })
+
+ t.Run("ring buffer keeps truncated line when in last N", func(t *testing.T) {
+ // Long line followed by only 2 more lines, with ring buffer size 5
+ longLine := strings.Repeat("y", 11*1024*1024)
+ body := "line1\nline2\nline3\n" + longLine + "\nlineA\nlineB\n"
+ resp := &http.Response{
+ Body: io.NopCloser(strings.NewReader(body)),
+ }
+
+ result, totalLines, respOut, err := ProcessResponseAsRingBufferToEnd(resp, 5)
+ if respOut != nil && respOut.Body != nil {
+ defer respOut.Body.Close()
+ }
+ require.NoError(t, err)
+ assert.Equal(t, 6, totalLines)
+ // Last 5: line2, line3, truncated, lineA, lineB
+ assert.Contains(t, result, "line2")
+ assert.Contains(t, result, "line3")
+ assert.Contains(t, result, "TRUNCATED")
+ assert.Contains(t, result, "lineA")
+ assert.Contains(t, result, "lineB")
+ // line1 should be rotated out
+ assert.NotContains(t, result, "line1")
+ })
+}
diff --git a/pkg/context/graphql_features.go b/pkg/context/graphql_features.go
new file mode 100644
index 000000000..ebba3f757
--- /dev/null
+++ b/pkg/context/graphql_features.go
@@ -0,0 +1,19 @@
+package context
+
+import "context"
+
+// 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/context/mcp_info.go b/pkg/context/mcp_info.go
new file mode 100644
index 000000000..ce5505682
--- /dev/null
+++ b/pkg/context/mcp_info.go
@@ -0,0 +1,39 @@
+package context
+
+import "context"
+
+type mcpMethodInfoCtx string
+
+var mcpMethodInfoCtxKey mcpMethodInfoCtx = "mcpmethodinfo"
+
+// MCPMethodInfo contains pre-parsed MCP method information extracted from the JSON-RPC request.
+// This is populated early in the request lifecycle to enable:
+// - Inventory filtering via ForMCPRequest (only register needed tools/resources/prompts)
+// - Avoiding duplicate JSON parsing in middlewares (secret-scanning, scope-challenge)
+// - Performance optimization for per-request server creation
+type MCPMethodInfo struct {
+ // Method is the MCP method being called (e.g., "tools/call", "tools/list", "initialize")
+ Method string
+ // ItemName is the name of the specific item being accessed (tool name, resource URI, prompt name)
+ // Only populated for call/get methods (tools/call, prompts/get, resources/read)
+ ItemName string
+ // Owner is the repository owner from tool call arguments, if present
+ Owner string
+ // Repo is the repository name from tool call arguments, if present
+ Repo string
+ // Arguments contains the raw tool arguments for tools/call requests
+ Arguments map[string]any
+}
+
+// WithMCPMethodInfo stores the MCPMethodInfo in the context.
+func WithMCPMethodInfo(ctx context.Context, info *MCPMethodInfo) context.Context {
+ return context.WithValue(ctx, mcpMethodInfoCtxKey, info)
+}
+
+// MCPMethod retrieves the MCPMethodInfo from the context.
+func MCPMethod(ctx context.Context) (*MCPMethodInfo, bool) {
+ if info, ok := ctx.Value(mcpMethodInfoCtxKey).(*MCPMethodInfo); ok {
+ return info, true
+ }
+ return nil, false
+}
diff --git a/pkg/context/request.go b/pkg/context/request.go
new file mode 100644
index 000000000..70867f32e
--- /dev/null
+++ b/pkg/context/request.go
@@ -0,0 +1,99 @@
+package context
+
+import "context"
+
+// readonlyCtxKey is a context key for read-only mode
+type readonlyCtxKey struct{}
+
+// WithReadonly adds read-only mode state to the context
+func WithReadonly(ctx context.Context, enabled bool) context.Context {
+ return context.WithValue(ctx, readonlyCtxKey{}, enabled)
+}
+
+// IsReadonly retrieves the read-only mode state from the context
+func IsReadonly(ctx context.Context) bool {
+ if enabled, ok := ctx.Value(readonlyCtxKey{}).(bool); ok {
+ return enabled
+ }
+ return false
+}
+
+// toolsetsCtxKey is a context key for the active toolsets
+type toolsetsCtxKey struct{}
+
+// WithToolsets adds the active toolsets to the context
+func WithToolsets(ctx context.Context, toolsets []string) context.Context {
+ return context.WithValue(ctx, toolsetsCtxKey{}, toolsets)
+}
+
+// GetToolsets retrieves the active toolsets from the context
+func GetToolsets(ctx context.Context) []string {
+ if toolsets, ok := ctx.Value(toolsetsCtxKey{}).([]string); ok {
+ return toolsets
+ }
+ return nil
+}
+
+// toolsCtxKey is a context key for tools
+type toolsCtxKey struct{}
+
+// WithTools adds the tools to the context
+func WithTools(ctx context.Context, tools []string) context.Context {
+ return context.WithValue(ctx, toolsCtxKey{}, tools)
+}
+
+// GetTools retrieves the tools from the context
+func GetTools(ctx context.Context) []string {
+ if tools, ok := ctx.Value(toolsCtxKey{}).([]string); ok {
+ return tools
+ }
+ return nil
+}
+
+// lockdownCtxKey is a context key for lockdown mode
+type lockdownCtxKey struct{}
+
+// WithLockdownMode adds lockdown mode state to the context
+func WithLockdownMode(ctx context.Context, enabled bool) context.Context {
+ return context.WithValue(ctx, lockdownCtxKey{}, enabled)
+}
+
+// IsLockdownMode retrieves the lockdown mode state from the context
+func IsLockdownMode(ctx context.Context) bool {
+ if enabled, ok := ctx.Value(lockdownCtxKey{}).(bool); ok {
+ return enabled
+ }
+ return false
+}
+
+// insidersCtxKey is a context key for insiders mode
+type insidersCtxKey struct{}
+
+// WithInsidersMode adds insiders mode state to the context
+func WithInsidersMode(ctx context.Context, enabled bool) context.Context {
+ return context.WithValue(ctx, insidersCtxKey{}, enabled)
+}
+
+// IsInsidersMode retrieves the insiders mode state from the context
+func IsInsidersMode(ctx context.Context) bool {
+ if enabled, ok := ctx.Value(insidersCtxKey{}).(bool); ok {
+ return enabled
+ }
+ return false
+}
+
+// headerFeaturesCtxKey is a context key for raw header feature flags
+type headerFeaturesCtxKey struct{}
+
+// WithHeaderFeatures stores the raw feature flags from the X-MCP-Features header into context
+func WithHeaderFeatures(ctx context.Context, features []string) context.Context {
+ return context.WithValue(ctx, headerFeaturesCtxKey{}, features)
+}
+
+// GetHeaderFeatures retrieves the raw feature flags from context
+func GetHeaderFeatures(ctx context.Context) []string {
+ if features, ok := ctx.Value(headerFeaturesCtxKey{}).([]string); ok {
+ return features
+ }
+ return nil
+}
diff --git a/pkg/context/token.go b/pkg/context/token.go
new file mode 100644
index 000000000..beddb02b2
--- /dev/null
+++ b/pkg/context/token.go
@@ -0,0 +1,32 @@
+package context
+
+import (
+ "context"
+
+ "github.com/github/github-mcp-server/pkg/utils"
+)
+
+// tokenCtxKey is a context key for authentication token information
+type tokenCtx string
+
+var tokenCtxKey tokenCtx = "tokenctx"
+
+type TokenInfo struct {
+ Token string
+ TokenType utils.TokenType
+ ScopesFetched bool
+ Scopes []string
+}
+
+// WithTokenInfo adds TokenInfo to the context
+func WithTokenInfo(ctx context.Context, tokenInfo *TokenInfo) context.Context {
+ return context.WithValue(ctx, tokenCtxKey, tokenInfo)
+}
+
+// GetTokenInfo retrieves the authentication token from the context
+func GetTokenInfo(ctx context.Context) (*TokenInfo, bool) {
+ if tokenInfo, ok := ctx.Value(tokenCtxKey).(*TokenInfo); ok {
+ return tokenInfo, true
+ }
+ return nil, false
+}
diff --git a/pkg/errors/error.go b/pkg/errors/error.go
index 095f8d5b7..93ea852a8 100644
--- a/pkg/errors/error.go
+++ b/pkg/errors/error.go
@@ -3,6 +3,7 @@ package errors
import (
"context"
"fmt"
+ "net/http"
"github.com/github/github-mcp-server/pkg/utils"
"github.com/google/go-github/v79/github"
@@ -44,10 +45,29 @@ func (e *GitHubGraphQLError) Error() string {
return fmt.Errorf("%s: %w", e.Message, e.Err).Error()
}
+type GitHubRawAPIError struct {
+ Message string `json:"message"`
+ Response *http.Response `json:"-"`
+ Err error `json:"-"`
+}
+
+func newGitHubRawAPIError(message string, resp *http.Response, err error) *GitHubRawAPIError {
+ return &GitHubRawAPIError{
+ Message: message,
+ Response: resp,
+ Err: err,
+ }
+}
+
+func (e *GitHubRawAPIError) Error() string {
+ return fmt.Errorf("%s: %w", e.Message, e.Err).Error()
+}
+
type GitHubErrorKey struct{}
type GitHubCtxErrors struct {
api []*GitHubAPIError
graphQL []*GitHubGraphQLError
+ raw []*GitHubRawAPIError
}
// ContextWithGitHubErrors updates or creates a context with a pointer to GitHub error information (to be used by middleware).
@@ -59,6 +79,7 @@ func ContextWithGitHubErrors(ctx context.Context) context.Context {
// If the context already has GitHubCtxErrors, we just empty the slices to start fresh
val.api = []*GitHubAPIError{}
val.graphQL = []*GitHubGraphQLError{}
+ val.raw = []*GitHubRawAPIError{}
} else {
// If not, we create a new GitHubCtxErrors and set it in the context
ctx = context.WithValue(ctx, GitHubErrorKey{}, &GitHubCtxErrors{})
@@ -83,6 +104,14 @@ func GetGitHubGraphQLErrors(ctx context.Context) ([]*GitHubGraphQLError, error)
return nil, fmt.Errorf("context does not contain GitHubCtxErrors")
}
+// GetGitHubRawAPIErrors retrieves the slice of GitHubRawAPIErrors from the context.
+func GetGitHubRawAPIErrors(ctx context.Context) ([]*GitHubRawAPIError, error) {
+ if val, ok := ctx.Value(GitHubErrorKey{}).(*GitHubCtxErrors); ok {
+ return val.raw, nil // return the slice of raw API errors from the context
+ }
+ return nil, fmt.Errorf("context does not contain GitHubCtxErrors")
+}
+
func NewGitHubAPIErrorToCtx(ctx context.Context, message string, resp *github.Response, err error) (context.Context, error) {
apiErr := newGitHubAPIError(message, resp, err)
if ctx != nil {
@@ -91,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
@@ -107,6 +144,15 @@ func addGitHubGraphQLErrorToContext(ctx context.Context, err *GitHubGraphQLError
return nil, fmt.Errorf("context does not contain GitHubCtxErrors")
}
+func addRawAPIErrorToContext(ctx context.Context, err *GitHubRawAPIError) (context.Context, error) {
+ if val, ok := ctx.Value(GitHubErrorKey{}).(*GitHubCtxErrors); ok {
+ val.raw = append(val.raw, err)
+ return ctx, nil
+ }
+
+ return nil, fmt.Errorf("context does not contain GitHubCtxErrors")
+}
+
// NewGitHubAPIErrorResponse returns an mcp.NewToolResultError and retains the error in the context for access via middleware
func NewGitHubAPIErrorResponse(ctx context.Context, message string, resp *github.Response, err error) *mcp.CallToolResult {
apiErr := newGitHubAPIError(message, resp, err)
@@ -125,6 +171,15 @@ func NewGitHubGraphQLErrorResponse(ctx context.Context, message string, err erro
return utils.NewToolResultErrorFromErr(message, err)
}
+// NewGitHubRawAPIErrorResponse returns an mcp.NewToolResultError and retains the error in the context for access via middleware
+func NewGitHubRawAPIErrorResponse(ctx context.Context, message string, resp *http.Response, err error) *mcp.CallToolResult {
+ rawErr := newGitHubRawAPIError(message, resp, err)
+ if ctx != nil {
+ _, _ = addRawAPIErrorToContext(ctx, rawErr) // Explicitly ignore error for graceful handling
+ }
+ return utils.NewToolResultErrorFromErr(message, err)
+}
+
// NewGitHubAPIStatusErrorResponse handles cases where the API call succeeds (err == nil)
// but returns an unexpected HTTP status code. It creates a synthetic error from the
// status code and response body, then records it in context for observability tracking.
diff --git a/pkg/errors/error_test.go b/pkg/errors/error_test.go
index b5ef40596..072a09a28 100644
--- a/pkg/errors/error_test.go
+++ b/pkg/errors/error_test.go
@@ -63,6 +63,33 @@ func TestGitHubErrorContext(t *testing.T) {
assert.Equal(t, "failed to execute mutation: GraphQL query failed", gqlError.Error())
})
+ t.Run("Raw API errors can be added to context and retrieved", func(t *testing.T) {
+ // Given a context with GitHub error tracking enabled
+ ctx := ContextWithGitHubErrors(context.Background())
+
+ // Create a mock HTTP response
+ resp := &http.Response{
+ StatusCode: 404,
+ Status: "404 Not Found",
+ }
+ originalErr := fmt.Errorf("raw content not found")
+
+ // When we add a raw API error to the context
+ rawAPIErr := newGitHubRawAPIError("failed to fetch raw content", resp, originalErr)
+ updatedCtx, err := addRawAPIErrorToContext(ctx, rawAPIErr)
+ require.NoError(t, err)
+
+ // Then we should be able to retrieve the error from the updated context
+ rawErrors, err := GetGitHubRawAPIErrors(updatedCtx)
+ require.NoError(t, err)
+ require.Len(t, rawErrors, 1)
+
+ rawError := rawErrors[0]
+ assert.Equal(t, "failed to fetch raw content", rawError.Message)
+ assert.Equal(t, resp, rawError.Response)
+ assert.Equal(t, originalErr, rawError.Err)
+ })
+
t.Run("multiple errors can be accumulated in context", func(t *testing.T) {
// Given a context with GitHub error tracking enabled
ctx := ContextWithGitHubErrors(context.Background())
@@ -82,6 +109,11 @@ func TestGitHubErrorContext(t *testing.T) {
ctx, err = addGitHubGraphQLErrorToContext(ctx, gqlErr)
require.NoError(t, err)
+ // And add a raw API error
+ rawErr := newGitHubRawAPIError("raw error", &http.Response{StatusCode: 404}, fmt.Errorf("not found"))
+ ctx, err = addRawAPIErrorToContext(ctx, rawErr)
+ require.NoError(t, err)
+
// Then we should be able to retrieve all errors
apiErrors, err := GetGitHubAPIErrors(ctx)
require.NoError(t, err)
@@ -91,10 +123,15 @@ func TestGitHubErrorContext(t *testing.T) {
require.NoError(t, err)
assert.Len(t, gqlErrors, 1)
+ rawErrors, err := GetGitHubRawAPIErrors(ctx)
+ require.NoError(t, err)
+ assert.Len(t, rawErrors, 1)
+
// Verify error details
assert.Equal(t, "first error", apiErrors[0].Message)
assert.Equal(t, "second error", apiErrors[1].Message)
assert.Equal(t, "graphql error", gqlErrors[0].Message)
+ assert.Equal(t, "raw error", rawErrors[0].Message)
})
t.Run("context pointer sharing allows middleware to inspect errors without context propagation", func(t *testing.T) {
@@ -160,6 +197,12 @@ func TestGitHubErrorContext(t *testing.T) {
assert.Error(t, err)
assert.Contains(t, err.Error(), "context does not contain GitHubCtxErrors")
assert.Nil(t, gqlErrors)
+
+ // Same for raw API errors
+ rawErrors, err := GetGitHubRawAPIErrors(ctx)
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "context does not contain GitHubCtxErrors")
+ assert.Nil(t, rawErrors)
})
t.Run("ContextWithGitHubErrors resets existing errors", func(t *testing.T) {
@@ -169,18 +212,31 @@ func TestGitHubErrorContext(t *testing.T) {
ctx, err := NewGitHubAPIErrorToCtx(ctx, "existing error", resp, fmt.Errorf("error"))
require.NoError(t, err)
- // Verify error exists
+ // Add a raw API error too
+ rawErr := newGitHubRawAPIError("existing raw error", &http.Response{StatusCode: 404}, fmt.Errorf("error"))
+ ctx, err = addRawAPIErrorToContext(ctx, rawErr)
+ require.NoError(t, err)
+
+ // Verify errors exist
apiErrors, err := GetGitHubAPIErrors(ctx)
require.NoError(t, err)
assert.Len(t, apiErrors, 1)
+ rawErrors, err := GetGitHubRawAPIErrors(ctx)
+ require.NoError(t, err)
+ assert.Len(t, rawErrors, 1)
+
// When we call ContextWithGitHubErrors again
resetCtx := ContextWithGitHubErrors(ctx)
- // Then the errors should be cleared
+ // Then all errors should be cleared
apiErrors, err = GetGitHubAPIErrors(resetCtx)
require.NoError(t, err)
- assert.Len(t, apiErrors, 0, "Errors should be reset")
+ assert.Len(t, apiErrors, 0, "API errors should be reset")
+
+ rawErrors, err = GetGitHubRawAPIErrors(resetCtx)
+ require.NoError(t, err)
+ assert.Len(t, rawErrors, 0, "Raw API errors should be reset")
})
t.Run("NewGitHubAPIErrorResponse creates MCP error result and stores context error", func(t *testing.T) {
diff --git a/pkg/github/__toolsnaps__/actions_get.snap b/pkg/github/__toolsnaps__/actions_get.snap
index b5f3b85bd..ba128875e 100644
--- a/pkg/github/__toolsnaps__/actions_get.snap
+++ b/pkg/github/__toolsnaps__/actions_get.snap
@@ -5,16 +5,8 @@
},
"description": "Get details about specific GitHub Actions resources.\nUse this tool to get details about individual workflows, workflow runs, jobs, and artifacts by their unique IDs.\n",
"inputSchema": {
- "type": "object",
- "required": [
- "method",
- "owner",
- "repo",
- "resource_id"
- ],
"properties": {
"method": {
- "type": "string",
"description": "The method to execute",
"enum": [
"get_workflow",
@@ -23,21 +15,29 @@
"download_workflow_run_artifact",
"get_workflow_run_usage",
"get_workflow_run_logs_url"
- ]
+ ],
+ "type": "string"
},
"owner": {
- "type": "string",
- "description": "Repository owner"
+ "description": "Repository owner",
+ "type": "string"
},
"repo": {
- "type": "string",
- "description": "Repository name"
+ "description": "Repository name",
+ "type": "string"
},
"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- Provide a workflow ID or workflow file name (e.g. ci.yaml) for 'get_workflow' method.\n- Provide a workflow run ID for 'get_workflow_run', 'get_workflow_run_usage', and 'get_workflow_run_logs_url' methods.\n- Provide an artifact ID for 'download_workflow_run_artifact' method.\n- Provide a job ID for 'get_workflow_job' method.\n"
+ "description": "The unique identifier of the resource. This will vary based on the \"method\" provided, so ensure you provide the correct ID:\n- Provide a workflow ID or workflow file name (e.g. ci.yaml) for 'get_workflow' method.\n- Provide a workflow run ID for 'get_workflow_run', 'get_workflow_run_usage', and 'get_workflow_run_logs_url' methods.\n- Provide an artifact ID for 'download_workflow_run_artifact' method.\n- Provide a job ID for 'get_workflow_job' method.\n",
+ "type": "string"
}
- }
+ },
+ "required": [
+ "method",
+ "owner",
+ "repo",
+ "resource_id"
+ ],
+ "type": "object"
},
"name": "actions_get"
}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/actions_list.snap b/pkg/github/__toolsnaps__/actions_list.snap
index 3968a6eae..a7e9ec56b 100644
--- a/pkg/github/__toolsnaps__/actions_list.snap
+++ b/pkg/github/__toolsnaps__/actions_list.snap
@@ -5,74 +5,66 @@
},
"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",
"description": "The action to perform",
"enum": [
"list_workflows",
"list_workflow_runs",
"list_workflow_jobs",
"list_workflow_run_artifacts"
- ]
+ ],
+ "type": "string"
},
"owner": {
- "type": "string",
- "description": "Repository owner"
+ "description": "Repository owner",
+ "type": "string"
},
"page": {
- "type": "number",
"description": "Page number for pagination (default: 1)",
- "minimum": 1
+ "minimum": 1,
+ "type": "number"
},
"per_page": {
- "type": "number",
"description": "Results per page for pagination (default: 30, max: 100)",
+ "maximum": 100,
"minimum": 1,
- "maximum": 100
+ "type": "number"
},
"repo": {
- "type": "string",
- "description": "Repository name"
+ "description": "Repository name",
+ "type": "string"
},
"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",
+ "type": "string"
},
"workflow_jobs_filter": {
- "type": "object",
"description": "Filters for workflow jobs. **ONLY** used when method is 'list_workflow_jobs'",
"properties": {
"filter": {
- "type": "string",
"description": "Filters jobs by their completed_at timestamp",
"enum": [
"latest",
"all"
- ]
+ ],
+ "type": "string"
}
- }
+ },
+ "type": "object"
},
"workflow_runs_filter": {
- "type": "object",
"description": "Filters for workflow runs. **ONLY** used when method is 'list_workflow_runs'",
"properties": {
"actor": {
- "type": "string",
- "description": "Filter to a specific GitHub user's workflow runs."
+ "description": "Filter to a specific GitHub user's workflow runs.",
+ "type": "string"
},
"branch": {
- "type": "string",
- "description": "Filter workflow runs to a specific Git branch. Use the name of the branch."
+ "description": "Filter workflow runs to a specific Git branch. Use the name of the branch.",
+ "type": "string"
},
"event": {
- "type": "string",
"description": "Filter workflow runs to a specific event type",
"enum": [
"branch_protection_rule",
@@ -107,10 +99,10 @@
"workflow_call",
"workflow_dispatch",
"workflow_run"
- ]
+ ],
+ "type": "string"
},
"status": {
- "type": "string",
"description": "Filter workflow runs to only runs with a specific status",
"enum": [
"queued",
@@ -118,11 +110,19 @@
"completed",
"requested",
"waiting"
- ]
+ ],
+ "type": "string"
}
- }
+ },
+ "type": "object"
}
- }
+ },
+ "required": [
+ "method",
+ "owner",
+ "repo"
+ ],
+ "type": "object"
},
"name": "actions_list"
}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/actions_run_trigger.snap b/pkg/github/__toolsnaps__/actions_run_trigger.snap
index 4e16f8958..c51501c17 100644
--- a/pkg/github/__toolsnaps__/actions_run_trigger.snap
+++ b/pkg/github/__toolsnaps__/actions_run_trigger.snap
@@ -5,19 +5,12 @@
},
"description": "Trigger GitHub Actions workflow operations, including running, re-running, cancelling workflow runs, and deleting workflow run logs.",
"inputSchema": {
- "type": "object",
- "required": [
- "method",
- "owner",
- "repo"
- ],
"properties": {
"inputs": {
- "type": "object",
- "description": "Inputs the workflow accepts. Only used for 'run_workflow' method."
+ "description": "Inputs the workflow accepts. Only used for 'run_workflow' method.",
+ "type": "object"
},
"method": {
- "type": "string",
"description": "The method to execute",
"enum": [
"run_workflow",
@@ -25,29 +18,36 @@
"rerun_failed_jobs",
"cancel_workflow_run",
"delete_workflow_run_logs"
- ]
+ ],
+ "type": "string"
},
"owner": {
- "type": "string",
- "description": "Repository owner"
+ "description": "Repository owner",
+ "type": "string"
},
"ref": {
- "type": "string",
- "description": "The git reference for the workflow. The reference can be a branch or tag name. Required for 'run_workflow' method."
+ "description": "The git reference for the workflow. The reference can be a branch or tag name. Required for 'run_workflow' method.",
+ "type": "string"
},
"repo": {
- "type": "string",
- "description": "Repository name"
+ "description": "Repository name",
+ "type": "string"
},
"run_id": {
- "type": "number",
- "description": "The ID of the workflow run. Required for all methods except 'run_workflow'."
+ "description": "The ID of the workflow run. Required for all methods except 'run_workflow'.",
+ "type": "number"
},
"workflow_id": {
- "type": "string",
- "description": "The workflow ID (numeric) or workflow file name (e.g., main.yml, ci.yaml). Required for 'run_workflow' method."
+ "description": "The workflow ID (numeric) or workflow file name (e.g., main.yml, ci.yaml). Required for 'run_workflow' method.",
+ "type": "string"
}
- }
+ },
+ "required": [
+ "method",
+ "owner",
+ "repo"
+ ],
+ "type": "object"
},
"name": "actions_run_trigger"
}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/add_comment_to_pending_review.snap b/pkg/github/__toolsnaps__/add_comment_to_pending_review.snap
index 78795c096..af4c41f52 100644
--- a/pkg/github/__toolsnaps__/add_comment_to_pending_review.snap
+++ b/pkg/github/__toolsnaps__/add_comment_to_pending_review.snap
@@ -4,69 +4,69 @@
},
"description": "Add review comment to the requester's latest pending pull request review. A pending review needs to already exist to call this (check with the user if not sure).",
"inputSchema": {
- "type": "object",
- "required": [
- "owner",
- "repo",
- "pullNumber",
- "path",
- "body",
- "subjectType"
- ],
"properties": {
"body": {
- "type": "string",
- "description": "The text of the review comment"
+ "description": "The text of the review comment",
+ "type": "string"
},
"line": {
- "type": "number",
- "description": "The line of the blob in the pull request diff that the comment applies to. For multi-line comments, the last line of the range"
+ "description": "The line of the blob in the pull request diff that the comment applies to. For multi-line comments, the last line of the range",
+ "type": "number"
},
"owner": {
- "type": "string",
- "description": "Repository owner"
+ "description": "Repository owner",
+ "type": "string"
},
"path": {
- "type": "string",
- "description": "The relative path to the file that necessitates a comment"
+ "description": "The relative path to the file that necessitates a comment",
+ "type": "string"
},
"pullNumber": {
- "type": "number",
- "description": "Pull request number"
+ "description": "Pull request number",
+ "type": "number"
},
"repo": {
- "type": "string",
- "description": "Repository name"
+ "description": "Repository name",
+ "type": "string"
},
"side": {
- "type": "string",
"description": "The side of the diff to comment on. LEFT indicates the previous state, RIGHT indicates the new state",
"enum": [
"LEFT",
"RIGHT"
- ]
+ ],
+ "type": "string"
},
"startLine": {
- "type": "number",
- "description": "For multi-line comments, the first line of the range that the comment applies to"
+ "description": "For multi-line comments, the first line of the range that the comment applies to",
+ "type": "number"
},
"startSide": {
- "type": "string",
"description": "For multi-line comments, the starting side of the diff that the comment applies to. LEFT indicates the previous state, RIGHT indicates the new state",
"enum": [
"LEFT",
"RIGHT"
- ]
+ ],
+ "type": "string"
},
"subjectType": {
- "type": "string",
"description": "The level at which the comment is targeted",
"enum": [
"FILE",
"LINE"
- ]
+ ],
+ "type": "string"
}
- }
+ },
+ "required": [
+ "owner",
+ "repo",
+ "pullNumber",
+ "path",
+ "body",
+ "subjectType"
+ ],
+ "type": "object"
},
"name": "add_comment_to_pending_review"
}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/add_issue_comment.snap b/pkg/github/__toolsnaps__/add_issue_comment.snap
index fb2a9e7b3..d273a582d 100644
--- a/pkg/github/__toolsnaps__/add_issue_comment.snap
+++ b/pkg/github/__toolsnaps__/add_issue_comment.snap
@@ -4,31 +4,31 @@
},
"description": "Add a comment to a specific issue in a GitHub repository. Use this tool to add comments to pull requests as well (in this case pass pull request number as issue_number), but only if user is not asking specifically to add review comments.",
"inputSchema": {
- "type": "object",
- "required": [
- "owner",
- "repo",
- "issue_number",
- "body"
- ],
"properties": {
"body": {
- "type": "string",
- "description": "Comment content"
+ "description": "Comment content",
+ "type": "string"
},
"issue_number": {
- "type": "number",
- "description": "Issue number to comment on"
+ "description": "Issue number to comment on",
+ "type": "number"
},
"owner": {
- "type": "string",
- "description": "Repository owner"
+ "description": "Repository owner",
+ "type": "string"
},
"repo": {
- "type": "string",
- "description": "Repository name"
+ "description": "Repository name",
+ "type": "string"
}
- }
+ },
+ "required": [
+ "owner",
+ "repo",
+ "issue_number",
+ "body"
+ ],
+ "type": "object"
},
"name": "add_issue_comment"
}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/add_project_item.snap b/pkg/github/__toolsnaps__/add_project_item.snap
index 08f495370..e6a5cc3c4 100644
--- a/pkg/github/__toolsnaps__/add_project_item.snap
+++ b/pkg/github/__toolsnaps__/add_project_item.snap
@@ -4,44 +4,44 @@
},
"description": "Add a specific Project item for a user or org",
"inputSchema": {
- "type": "object",
- "required": [
- "owner_type",
- "owner",
- "project_number",
- "item_type",
- "item_id"
- ],
"properties": {
"item_id": {
- "type": "number",
- "description": "The numeric ID of the issue or pull request to add to the project."
+ "description": "The numeric ID of the issue or pull request to add to the project.",
+ "type": "number"
},
"item_type": {
- "type": "string",
"description": "The item's type, either issue or pull_request.",
"enum": [
"issue",
"pull_request"
- ]
+ ],
+ "type": "string"
},
"owner": {
- "type": "string",
- "description": "If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive."
+ "description": "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.",
+ "type": "string"
},
"owner_type": {
- "type": "string",
"description": "Owner type",
"enum": [
"user",
"org"
- ]
+ ],
+ "type": "string"
},
"project_number": {
- "type": "number",
- "description": "The project's number."
+ "description": "The project's number.",
+ "type": "number"
}
- }
+ },
+ "required": [
+ "owner_type",
+ "owner",
+ "project_number",
+ "item_type",
+ "item_id"
+ ],
+ "type": "object"
},
"name": "add_project_item"
}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/add_reply_to_pull_request_comment.snap b/pkg/github/__toolsnaps__/add_reply_to_pull_request_comment.snap
new file mode 100644
index 000000000..e2187478e
--- /dev/null
+++ b/pkg/github/__toolsnaps__/add_reply_to_pull_request_comment.snap
@@ -0,0 +1,39 @@
+{
+ "annotations": {
+ "title": "Add reply to pull request comment"
+ },
+ "description": "Add a reply to an existing pull request comment. This creates a new comment that is linked as a reply to the specified comment.",
+ "inputSchema": {
+ "properties": {
+ "body": {
+ "description": "The text of the reply",
+ "type": "string"
+ },
+ "commentId": {
+ "description": "The ID of the comment to reply to",
+ "type": "number"
+ },
+ "owner": {
+ "description": "Repository owner",
+ "type": "string"
+ },
+ "pullNumber": {
+ "description": "Pull request number",
+ "type": "number"
+ },
+ "repo": {
+ "description": "Repository name",
+ "type": "string"
+ }
+ },
+ "required": [
+ "owner",
+ "repo",
+ "pullNumber",
+ "commentId",
+ "body"
+ ],
+ "type": "object"
+ },
+ "name": "add_reply_to_pull_request_comment"
+}
\ 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 aff4aa597..9c105267b 100644
--- a/pkg/github/__toolsnaps__/assign_copilot_to_issue.snap
+++ b/pkg/github/__toolsnaps__/assign_copilot_to_issue.snap
@@ -4,45 +4,47 @@
"title": "Assign Copilot to issue"
},
"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": {
- "type": "number",
- "description": "Issue number"
- },
- "owner": {
- "type": "string",
- "description": "Repository owner"
- },
- "repo": {
- "type": "string",
- "description": "Repository name"
- }
- }
- },
- "name": "assign_copilot_to_issue",
"icons": [
{
- "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAC20lEQVRIidWUS4wMURSGv3O7kWmPEMRrSMzcbl1dpqtmGuOxsCKECCKxEBusSJhIWEhsWLFAbC1sWFiISBARCyQ2kzSZGaMxHokgXvGIiMH0PRZjpJqqHpb+TeX+59z//H/q5sD/DqlX9H1/zFeX2qzIKoFWYDKgwBtUymL0UkNaT3V3d3/+5wG2EGxB9TDIxGFMvhVhb9/drpN/NaDJC7MGdwJk6TDCv0Gvq0lve9R762GUNdFDLleaZNBrICGq+4yhvf9TJtP/KZNB2PrLlbBliBfRhajuAwnFVa/n8/nkxFkv3GO9oJrzgwVxdesV71ov6I2r5fxggfWCatYL9yYmUJgLPH7Q29WZ4OED6Me4wuAdeQK6MMqna9t0GuibBHFAmgZ9JMG9BhkXZWoSCDSATIq7aguBD0wBplq/tZBgYDIwKnZAs99mFRYD9vd/YK0dpcqhobM6d9haWyOULRTbAauwuNlvsxHTYP3iBnVyXGAa8BIYC3oVeAKioCtAPEE7FCOgR0ErIJdBBZgNskzh40+NF6K6s+9e91lp9osrxMnFoTSmSmPVsF+E5cB0YEDgtoMjjypd5wCy+WC9GnajhEAa4bkqV9LOHKwa9/yneYeyUqwX3AdyQ5EeVrrqro/hYL0g+ggemKh4HGbPmVu0+fB8U76lpR6XgJwZpoGUpNYiusZg1tXjkmCAav0OMTXfJC4eVYPqwbot6l4BCPqyLhd7lwMAWC/cYb3gi/UCzRaKOxsbFzVEM1iv2Ebt5v2Dm14qZbJecZf1Ah3UCrcTbbB+awHnjgHLgHeinHYqZ8aPSXWWy+XvcQZLpdKI9/0D7UbZiLIJmABckVSqo+/OrUrNgF+D8q1LEdcBrAJGAJ8ROlGeicorABWdAswE5gOjge8CF8Ad66v03IjqJb75WS0tE0YOmNWqLBGReaAzgIkMLrt3oM9UpSzCzW9pd+FpT8/7JK3/Gz8Ao5X6wtwP7N4AAAAASUVORK5CYII=",
"mimeType": "image/png",
- "sizes": [
- "24x24"
- ],
+ "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAC20lEQVRIidWUS4wMURSGv3O7kWmPEMRrSMzcbl1dpqtmGuOxsCKECCKxEBusSJhIWEhsWLFAbC1sWFiISBARCyQ2kzSZGaMxHokgXvGIiMH0PRZjpJqqHpb+TeX+59z//H/q5sD/DqlX9H1/zFeX2qzIKoFWYDKgwBtUymL0UkNaT3V3d3/+5wG2EGxB9TDIxGFMvhVhb9/drpN/NaDJC7MGdwJk6TDCv0Gvq0lve9R762GUNdFDLleaZNBrICGq+4yhvf9TJtP/KZNB2PrLlbBliBfRhajuAwnFVa/n8/nkxFkv3GO9oJrzgwVxdesV71ov6I2r5fxggfWCatYL9yYmUJgLPH7Q29WZ4OED6Me4wuAdeQK6MMqna9t0GuibBHFAmgZ9JMG9BhkXZWoSCDSATIq7aguBD0wBplq/tZBgYDIwKnZAs99mFRYD9vd/YK0dpcqhobM6d9haWyOULRTbAauwuNlvsxHTYP3iBnVyXGAa8BIYC3oVeAKioCtAPEE7FCOgR0ErIJdBBZgNskzh40+NF6K6s+9e91lp9osrxMnFoTSmSmPVsF+E5cB0YEDgtoMjjypd5wCy+WC9GnajhEAa4bkqV9LOHKwa9/yneYeyUqwX3AdyQ5EeVrrqro/hYL0g+ggemKh4HGbPmVu0+fB8U76lpR6XgJwZpoGUpNYiusZg1tXjkmCAav0OMTXfJC4eVYPqwbot6l4BCPqyLhd7lwMAWC/cYb3gi/UCzRaKOxsbFzVEM1iv2Ebt5v2Dm14qZbJecZf1Ah3UCrcTbbB+awHnjgHLgHeinHYqZ8aPSXWWy+XvcQZLpdKI9/0D7UbZiLIJmABckVSqo+/OrUrNgF+D8q1LEdcBrAJGAJ8ROlGeicorABWdAswE5gOjge8CF8Ad66v03IjqJb75WS0tE0YOmNWqLBGReaAzgIkMLrt3oM9UpSzCzW9pd+FpT8/7JK3/Gz8Ao5X6wtwP7N4AAAAASUVORK5CYII=",
"theme": "light"
},
{
- "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAACCElEQVRIid2UPWsUYRSFn3dxWWJUkESiBgslFokfhehGiGClBBQx4h9IGlEh2ijYxh+gxEL/hIWwhYpF8KNZsFRJYdJEiUbjCkqisj4W+y6Mk5nd1U4PDMOce+45L3fmDvzXUDeo59WK+kb9rn5TF9R76jm1+2/NJ9QPtseSOv4nxrvVmQ6M05hRB9qZ98ZR1NRralntitdEwmw8wQ9HbS329rQKuKLW1XJO/aX6IqdWjr1Xk/y6lG4vMBdCqOacoZZ3uBBCVZ0HDrcK2AYs5ZkAuwBb1N8Dm5JEISXoAnqzOtU9QB+wVR3KCdgClDIr6kCc4c/0O1BLNnahiYpaSmmGY62e/JpCLJ4FpmmMaBHYCDwC5mmMZBQYBC7HnhvAK+B+fN4JHAM+R4+3wGQI4S7qaExtol+9o86pq+oX9Yk6ljjtGfVprK2qr9Xb6vaET109jjqb3Jac2XaM1PLNpok1Aep+G/+dfa24nADTX1EWTgOngLE2XCYKQL0DTfKex2WhXgCutxG9i/fFNlwWpgBQL6orcWyTaldToRbUA2pow61XL0WPFfXCb1HqkPowCj6q0+qIWsw7nlpUj6i31OXY+0AdbGpCRtNRGgt1AigCX4EqsJAYTR+wAzgEdAM/gApwM4TwOOm3JiARtBk4CYwAB4F+oIfGZi/HwOfAM6ASQviU5/Vv4xcBzmW2eT1nrQAAAABJRU5ErkJggg==",
"mimeType": "image/png",
- "sizes": [
- "24x24"
- ],
+ "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAACCElEQVRIid2UPWsUYRSFn3dxWWJUkESiBgslFokfhehGiGClBBQx4h9IGlEh2ijYxh+gxEL/hIWwhYpF8KNZsFRJYdJEiUbjCkqisj4W+y6Mk5nd1U4PDMOce+45L3fmDvzXUDeo59WK+kb9rn5TF9R76jm1+2/NJ9QPtseSOv4nxrvVmQ6M05hRB9qZ98ZR1NRralntitdEwmw8wQ9HbS329rQKuKLW1XJO/aX6IqdWjr1Xk/y6lG4vMBdCqOacoZZ3uBBCVZ0HDrcK2AYs5ZkAuwBb1N8Dm5JEISXoAnqzOtU9QB+wVR3KCdgClDIr6kCc4c/0O1BLNnahiYpaSmmGY62e/JpCLJ4FpmmMaBHYCDwC5mmMZBQYBC7HnhvAK+B+fN4JHAM+R4+3wGQI4S7qaExtol+9o86pq+oX9Yk6ljjtGfVprK2qr9Xb6vaET109jjqb3Jac2XaM1PLNpok1Aep+G/+dfa24nADTX1EWTgOngLE2XCYKQL0DTfKex2WhXgCutxG9i/fFNlwWpgBQL6orcWyTaldToRbUA2pow61XL0WPFfXCb1HqkPowCj6q0+qIWsw7nlpUj6i31OXY+0AdbGpCRtNRGgt1AigCX4EqsJAYTR+wAzgEdAM/gApwM4TwOOm3JiARtBk4CYwAB4F+oIfGZi/HwOfAM6ASQviU5/Vv4xcBzmW2eT1nrQAAAABJRU5ErkJggg==",
"theme": "dark"
}
- ]
+ ],
+ "inputSchema": {
+ "properties": {
+ "base_ref": {
+ "description": "Git reference (e.g., branch) that the agent will start its work from. If not specified, defaults to the repository's default branch",
+ "type": "string"
+ },
+ "custom_instructions": {
+ "description": "Optional custom instructions to guide the agent beyond the issue body. Use this to provide additional context, constraints, or guidance that is not captured in the issue description",
+ "type": "string"
+ },
+ "issue_number": {
+ "description": "Issue number",
+ "type": "number"
+ },
+ "owner": {
+ "description": "Repository owner",
+ "type": "string"
+ },
+ "repo": {
+ "description": "Repository name",
+ "type": "string"
+ }
+ },
+ "required": [
+ "owner",
+ "repo",
+ "issue_number"
+ ],
+ "type": "object"
+ },
+ "name": "assign_copilot_to_issue"
}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/cancel_workflow_run.snap b/pkg/github/__toolsnaps__/cancel_workflow_run.snap
index 83eb31a7f..40bcae740 100644
--- a/pkg/github/__toolsnaps__/cancel_workflow_run.snap
+++ b/pkg/github/__toolsnaps__/cancel_workflow_run.snap
@@ -4,26 +4,26 @@
},
"description": "Cancel a workflow run",
"inputSchema": {
- "type": "object",
- "required": [
- "owner",
- "repo",
- "run_id"
- ],
"properties": {
"owner": {
- "type": "string",
- "description": "Repository owner"
+ "description": "Repository owner",
+ "type": "string"
},
"repo": {
- "type": "string",
- "description": "Repository name"
+ "description": "Repository name",
+ "type": "string"
},
"run_id": {
- "type": "number",
- "description": "The unique identifier of the workflow run"
+ "description": "The unique identifier of the workflow run",
+ "type": "number"
}
- }
+ },
+ "required": [
+ "owner",
+ "repo",
+ "run_id"
+ ],
+ "type": "object"
},
"name": "cancel_workflow_run"
}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/create_branch.snap b/pkg/github/__toolsnaps__/create_branch.snap
index 675a2de9c..a561247ef 100644
--- a/pkg/github/__toolsnaps__/create_branch.snap
+++ b/pkg/github/__toolsnaps__/create_branch.snap
@@ -4,30 +4,30 @@
},
"description": "Create a new branch in a GitHub repository",
"inputSchema": {
- "type": "object",
- "required": [
- "owner",
- "repo",
- "branch"
- ],
"properties": {
"branch": {
- "type": "string",
- "description": "Name for new branch"
+ "description": "Name for new branch",
+ "type": "string"
},
"from_branch": {
- "type": "string",
- "description": "Source branch (defaults to repo default)"
+ "description": "Source branch (defaults to repo default)",
+ "type": "string"
},
"owner": {
- "type": "string",
- "description": "Repository owner"
+ "description": "Repository owner",
+ "type": "string"
},
"repo": {
- "type": "string",
- "description": "Repository name"
+ "description": "Repository name",
+ "type": "string"
}
- }
+ },
+ "required": [
+ "owner",
+ "repo",
+ "branch"
+ ],
+ "type": "object"
},
"name": "create_branch"
}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/create_gist.snap b/pkg/github/__toolsnaps__/create_gist.snap
index 465206ab4..0ef05aa4a 100644
--- a/pkg/github/__toolsnaps__/create_gist.snap
+++ b/pkg/github/__toolsnaps__/create_gist.snap
@@ -4,30 +4,30 @@
},
"description": "Create a new gist",
"inputSchema": {
- "type": "object",
- "required": [
- "filename",
- "content"
- ],
"properties": {
"content": {
- "type": "string",
- "description": "Content for simple single-file gist creation"
+ "description": "Content for simple single-file gist creation",
+ "type": "string"
},
"description": {
- "type": "string",
- "description": "Description of the gist"
+ "description": "Description of the gist",
+ "type": "string"
},
"filename": {
- "type": "string",
- "description": "Filename for simple single-file gist creation"
+ "description": "Filename for simple single-file gist creation",
+ "type": "string"
},
"public": {
- "type": "boolean",
+ "default": false,
"description": "Whether the gist is public",
- "default": false
+ "type": "boolean"
}
- }
+ },
+ "required": [
+ "filename",
+ "content"
+ ],
+ "type": "object"
},
"name": "create_gist"
}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/create_or_update_file.snap b/pkg/github/__toolsnaps__/create_or_update_file.snap
index 2d9ae1144..9d28c8085 100644
--- a/pkg/github/__toolsnaps__/create_or_update_file.snap
+++ b/pkg/github/__toolsnaps__/create_or_update_file.snap
@@ -4,45 +4,45 @@
},
"description": "Create or update a single file in a GitHub repository. \nIf updating, you should provide the SHA of the file you want to update. Use this tool to create or update a file in a GitHub repository remotely; do not use it for local file operations.\n\nIn order to obtain the SHA of original file version before updating, use the following git command:\ngit ls-tree HEAD \u003cpath to file\u003e\n\nIf the SHA is not provided, the tool will attempt to acquire it by fetching the current file contents from the repository, which may lead to rewriting latest committed changes if the file has changed since last retrieval.\n",
"inputSchema": {
- "type": "object",
- "required": [
- "owner",
- "repo",
- "path",
- "content",
- "message",
- "branch"
- ],
"properties": {
"branch": {
- "type": "string",
- "description": "Branch to create/update the file in"
+ "description": "Branch to create/update the file in",
+ "type": "string"
},
"content": {
- "type": "string",
- "description": "Content of the file"
+ "description": "Content of the file",
+ "type": "string"
},
"message": {
- "type": "string",
- "description": "Commit message"
+ "description": "Commit message",
+ "type": "string"
},
"owner": {
- "type": "string",
- "description": "Repository owner (username or organization)"
+ "description": "Repository owner (username or organization)",
+ "type": "string"
},
"path": {
- "type": "string",
- "description": "Path where to create/update the file"
+ "description": "Path where to create/update the file",
+ "type": "string"
},
"repo": {
- "type": "string",
- "description": "Repository name"
+ "description": "Repository name",
+ "type": "string"
},
"sha": {
- "type": "string",
- "description": "The blob SHA of the file being replaced."
+ "description": "The blob SHA of the file being replaced.",
+ "type": "string"
}
- }
+ },
+ "required": [
+ "owner",
+ "repo",
+ "path",
+ "content",
+ "message",
+ "branch"
+ ],
+ "type": "object"
},
"name": "create_or_update_file"
}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/create_pull_request.snap b/pkg/github/__toolsnaps__/create_pull_request.snap
index 80f0b9863..cc22897fa 100644
--- a/pkg/github/__toolsnaps__/create_pull_request.snap
+++ b/pkg/github/__toolsnaps__/create_pull_request.snap
@@ -4,48 +4,48 @@
},
"description": "Create a new pull request in a GitHub repository.",
"inputSchema": {
- "type": "object",
- "required": [
- "owner",
- "repo",
- "title",
- "head",
- "base"
- ],
"properties": {
"base": {
- "type": "string",
- "description": "Branch to merge into"
+ "description": "Branch to merge into",
+ "type": "string"
},
"body": {
- "type": "string",
- "description": "PR description"
+ "description": "PR description",
+ "type": "string"
},
"draft": {
- "type": "boolean",
- "description": "Create as draft PR"
+ "description": "Create as draft PR",
+ "type": "boolean"
},
"head": {
- "type": "string",
- "description": "Branch containing changes"
+ "description": "Branch containing changes",
+ "type": "string"
},
"maintainer_can_modify": {
- "type": "boolean",
- "description": "Allow maintainer edits"
+ "description": "Allow maintainer edits",
+ "type": "boolean"
},
"owner": {
- "type": "string",
- "description": "Repository owner"
+ "description": "Repository owner",
+ "type": "string"
},
"repo": {
- "type": "string",
- "description": "Repository name"
+ "description": "Repository name",
+ "type": "string"
},
"title": {
- "type": "string",
- "description": "PR title"
+ "description": "PR title",
+ "type": "string"
}
- }
+ },
+ "required": [
+ "owner",
+ "repo",
+ "title",
+ "head",
+ "base"
+ ],
+ "type": "object"
},
"name": "create_pull_request"
}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/create_repository.snap b/pkg/github/__toolsnaps__/create_repository.snap
index 290767c66..2cc4227b2 100644
--- a/pkg/github/__toolsnaps__/create_repository.snap
+++ b/pkg/github/__toolsnaps__/create_repository.snap
@@ -4,32 +4,32 @@
},
"description": "Create a new GitHub repository in your account or specified organization",
"inputSchema": {
- "type": "object",
- "required": [
- "name"
- ],
"properties": {
"autoInit": {
- "type": "boolean",
- "description": "Initialize with README"
+ "description": "Initialize with README",
+ "type": "boolean"
},
"description": {
- "type": "string",
- "description": "Repository description"
+ "description": "Repository description",
+ "type": "string"
},
"name": {
- "type": "string",
- "description": "Repository name"
+ "description": "Repository name",
+ "type": "string"
},
"organization": {
- "type": "string",
- "description": "Organization to create the repository in (omit to create in your personal account)"
+ "description": "Organization to create the repository in (omit to create in your personal account)",
+ "type": "string"
},
"private": {
- "type": "boolean",
- "description": "Whether repo should be private"
+ "description": "Whether repo should be private",
+ "type": "boolean"
}
- }
+ },
+ "required": [
+ "name"
+ ],
+ "type": "object"
},
"name": "create_repository"
}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/delete_file.snap b/pkg/github/__toolsnaps__/delete_file.snap
index b985154e8..ff110ff78 100644
--- a/pkg/github/__toolsnaps__/delete_file.snap
+++ b/pkg/github/__toolsnaps__/delete_file.snap
@@ -5,36 +5,36 @@
},
"description": "Delete a file from a GitHub repository",
"inputSchema": {
- "type": "object",
- "required": [
- "owner",
- "repo",
- "path",
- "message",
- "branch"
- ],
"properties": {
"branch": {
- "type": "string",
- "description": "Branch to delete the file from"
+ "description": "Branch to delete the file from",
+ "type": "string"
},
"message": {
- "type": "string",
- "description": "Commit message"
+ "description": "Commit message",
+ "type": "string"
},
"owner": {
- "type": "string",
- "description": "Repository owner (username or organization)"
+ "description": "Repository owner (username or organization)",
+ "type": "string"
},
"path": {
- "type": "string",
- "description": "Path to the file to delete"
+ "description": "Path to the file to delete",
+ "type": "string"
},
"repo": {
- "type": "string",
- "description": "Repository name"
+ "description": "Repository name",
+ "type": "string"
}
- }
+ },
+ "required": [
+ "owner",
+ "repo",
+ "path",
+ "message",
+ "branch"
+ ],
+ "type": "object"
},
"name": "delete_file"
}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/delete_project_item.snap b/pkg/github/__toolsnaps__/delete_project_item.snap
index d768df10f..819fb8474 100644
--- a/pkg/github/__toolsnaps__/delete_project_item.snap
+++ b/pkg/github/__toolsnaps__/delete_project_item.snap
@@ -1,38 +1,39 @@
{
"annotations": {
+ "destructiveHint": true,
"title": "Delete project item"
},
"description": "Delete a specific Project item for a user or org",
"inputSchema": {
- "type": "object",
- "required": [
- "owner_type",
- "owner",
- "project_number",
- "item_id"
- ],
"properties": {
"item_id": {
- "type": "number",
- "description": "The internal project item ID to delete from the project (not the issue or pull request ID)."
+ "description": "The internal project item ID to delete from the project (not the issue or pull request ID).",
+ "type": "number"
},
"owner": {
- "type": "string",
- "description": "If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive."
+ "description": "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.",
+ "type": "string"
},
"owner_type": {
- "type": "string",
"description": "Owner type",
"enum": [
"user",
"org"
- ]
+ ],
+ "type": "string"
},
"project_number": {
- "type": "number",
- "description": "The project's number."
+ "description": "The project's number.",
+ "type": "number"
}
- }
+ },
+ "required": [
+ "owner_type",
+ "owner",
+ "project_number",
+ "item_id"
+ ],
+ "type": "object"
},
"name": "delete_project_item"
}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/delete_workflow_run_logs.snap b/pkg/github/__toolsnaps__/delete_workflow_run_logs.snap
index fc9a5cd46..2e2de7331 100644
--- a/pkg/github/__toolsnaps__/delete_workflow_run_logs.snap
+++ b/pkg/github/__toolsnaps__/delete_workflow_run_logs.snap
@@ -5,26 +5,26 @@
},
"description": "Delete logs for a workflow run",
"inputSchema": {
- "type": "object",
- "required": [
- "owner",
- "repo",
- "run_id"
- ],
"properties": {
"owner": {
- "type": "string",
- "description": "Repository owner"
+ "description": "Repository owner",
+ "type": "string"
},
"repo": {
- "type": "string",
- "description": "Repository name"
+ "description": "Repository name",
+ "type": "string"
},
"run_id": {
- "type": "number",
- "description": "The unique identifier of the workflow run"
+ "description": "The unique identifier of the workflow run",
+ "type": "number"
}
- }
+ },
+ "required": [
+ "owner",
+ "repo",
+ "run_id"
+ ],
+ "type": "object"
},
"name": "delete_workflow_run_logs"
}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/dismiss_notification.snap b/pkg/github/__toolsnaps__/dismiss_notification.snap
index b0125ba53..6c65d9ce0 100644
--- a/pkg/github/__toolsnaps__/dismiss_notification.snap
+++ b/pkg/github/__toolsnaps__/dismiss_notification.snap
@@ -4,25 +4,25 @@
},
"description": "Dismiss a notification by marking it as read or done",
"inputSchema": {
- "type": "object",
- "required": [
- "threadID",
- "state"
- ],
"properties": {
"state": {
- "type": "string",
"description": "The new state of the notification (read/done)",
"enum": [
"read",
"done"
- ]
+ ],
+ "type": "string"
},
"threadID": {
- "type": "string",
- "description": "The ID of the notification thread"
+ "description": "The ID of the notification thread",
+ "type": "string"
}
- }
+ },
+ "required": [
+ "threadID",
+ "state"
+ ],
+ "type": "object"
},
"name": "dismiss_notification"
}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/download_workflow_run_artifact.snap b/pkg/github/__toolsnaps__/download_workflow_run_artifact.snap
index c4d89872c..e831b21d5 100644
--- a/pkg/github/__toolsnaps__/download_workflow_run_artifact.snap
+++ b/pkg/github/__toolsnaps__/download_workflow_run_artifact.snap
@@ -5,26 +5,26 @@
},
"description": "Get download URL for a workflow run artifact",
"inputSchema": {
- "type": "object",
- "required": [
- "owner",
- "repo",
- "artifact_id"
- ],
"properties": {
"artifact_id": {
- "type": "number",
- "description": "The unique identifier of the artifact"
+ "description": "The unique identifier of the artifact",
+ "type": "number"
},
"owner": {
- "type": "string",
- "description": "Repository owner"
+ "description": "Repository owner",
+ "type": "string"
},
"repo": {
- "type": "string",
- "description": "Repository name"
+ "description": "Repository name",
+ "type": "string"
}
- }
+ },
+ "required": [
+ "owner",
+ "repo",
+ "artifact_id"
+ ],
+ "type": "object"
},
"name": "download_workflow_run_artifact"
}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/fork_repository.snap b/pkg/github/__toolsnaps__/fork_repository.snap
index ad48688b1..d635734a9 100644
--- a/pkg/github/__toolsnaps__/fork_repository.snap
+++ b/pkg/github/__toolsnaps__/fork_repository.snap
@@ -3,44 +3,38 @@
"title": "Fork repository"
},
"description": "Fork a GitHub repository to your account or specified organization",
- "inputSchema": {
- "type": "object",
- "required": [
- "owner",
- "repo"
- ],
- "properties": {
- "organization": {
- "type": "string",
- "description": "Organization to fork to"
- },
- "owner": {
- "type": "string",
- "description": "Repository owner"
- },
- "repo": {
- "type": "string",
- "description": "Repository name"
- }
- }
- },
- "name": "fork_repository",
"icons": [
{
- "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAACuElEQVRIibWTTUhUYRiFn/fOdYyoydQxk4LEGzN3RudaLYL+qRaBQYsIItoHCW37ISNbRwUFLWoRZBEt+4EIooKoTdZQ6TWaNIgouzJkuGhG731b6JTojDNBntX3ne+c97zfH8wzZCbREm9bZ4hsQvkeDvl3+/r6xuYqEIvFFgdSvRuDqCrPMu6bVyUDrITTjdI1jR8KBbrj/fs3Q8WLp5p9Qx4BzVOUInIm058+XdAY0ztH6RLhSpAza1RlI2jENzhfqntfjAugEdTYMFEtS0GvonrKslNrZwWIhDYDMh6Wo4ODvaMfB9LPFaMHZGvJ8xHdAlzPDLx+8Smd/pE39SggAptnB2gwDBD6ReJvhSCpMFyq/uSa/NFX5UMJgGCaxywMwiH/bi4wh0SCOy1x5waiCUF2gnSW3AByEfSSZTsPVXFF9CDC4ALx7xU0ocLA87x8tG7ZHRUShsheVMKInMy46culArIj317WRpd7KB2GsAl4bKoccN2330t5ALBsJ7ASTvecoun6hNNt2U5QbM0oRip8E6Wt0gCUFPC12FKoGFnX0BgBDtVGG3/W1qzqz2a/5IrpLGt9pLahvhPhCKrnsiPDT2dqZv1kgGQyGc4FZg+wr8I93F6y0DzY29s7XlHAnw7j7dswgg2oRCYZPTBluzk51VEwXmQG0k8qbGRuWHbqiWWn/qlY0Uv+n5j3gKKvaCaSyeSimrqms4hsB4kurW9c0bSs/pnneflyXrOcACCn5jWEPSr0AAgczvlVTVT+ykojFlvTZNmOWvHU8QJnJVInLNtR2163vJy/7B0EpjYAqBhugVMVF8A3goZy/rJHFGa8P4fpCXosHm9PqwbiwzHAqyLvlvPP+dEKWG23dyh6C1g0RY0Jsv+Dm77/XwIAWlpbVzJh7gLAnHjw8d27z5V65xW/AVGM6Ekx9nZCAAAAAElFTkSuQmCC",
"mimeType": "image/png",
- "sizes": [
- "24x24"
- ],
+ "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAACuElEQVRIibWTTUhUYRiFn/fOdYyoydQxk4LEGzN3RudaLYL+qRaBQYsIItoHCW37ISNbRwUFLWoRZBEt+4EIooKoTdZQ6TWaNIgouzJkuGhG731b6JTojDNBntX3ne+c97zfH8wzZCbREm9bZ4hsQvkeDvl3+/r6xuYqEIvFFgdSvRuDqCrPMu6bVyUDrITTjdI1jR8KBbrj/fs3Q8WLp5p9Qx4BzVOUInIm058+XdAY0ztH6RLhSpAza1RlI2jENzhfqntfjAugEdTYMFEtS0GvonrKslNrZwWIhDYDMh6Wo4ODvaMfB9LPFaMHZGvJ8xHdAlzPDLx+8Smd/pE39SggAptnB2gwDBD6ReJvhSCpMFyq/uSa/NFX5UMJgGCaxywMwiH/bi4wh0SCOy1x5waiCUF2gnSW3AByEfSSZTsPVXFF9CDC4ALx7xU0ocLA87x8tG7ZHRUShsheVMKInMy46culArIj317WRpd7KB2GsAl4bKoccN2330t5ALBsJ7ASTvecoun6hNNt2U5QbM0oRip8E6Wt0gCUFPC12FKoGFnX0BgBDtVGG3/W1qzqz2a/5IrpLGt9pLahvhPhCKrnsiPDT2dqZv1kgGQyGc4FZg+wr8I93F6y0DzY29s7XlHAnw7j7dswgg2oRCYZPTBluzk51VEwXmQG0k8qbGRuWHbqiWWn/qlY0Uv+n5j3gKKvaCaSyeSimrqms4hsB4kurW9c0bSs/pnneflyXrOcACCn5jWEPSr0AAgczvlVTVT+ykojFlvTZNmOWvHU8QJnJVInLNtR2163vJy/7B0EpjYAqBhugVMVF8A3goZy/rJHFGa8P4fpCXosHm9PqwbiwzHAqyLvlvPP+dEKWG23dyh6C1g0RY0Jsv+Dm77/XwIAWlpbVzJh7gLAnHjw8d27z5V65xW/AVGM6Ekx9nZCAAAAAElFTkSuQmCC",
"theme": "light"
},
{
- "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABoUlEQVRIibWUPS+EQRSFz0hsxaIgIZHYllDYiiiUEn6Bn0Dho6Nhf4GPjkYn8ZEgGqFRSNBoVTQKdhEROsk+mrt2svvu7Gutk7yZzJlz77nzztyR/hmulADSkkYk5SQdO+c+QwmAZkkTktolXTjnbkLiDJCniHsgFdCnTFNAHliuJE6bYANoAYaBF+AwYHBkmiGgFdi0HINR4lmrotXjVoG3gMEbsOLN2yzHTIFr8PRZG3s9rs/jo5At0fd6fFk1TfY/X4A14MyqmQrsYNo0pxbzCtwBTZUCUsAh8GHCKaDspnl6ZyZ3FnMA9AR2/BOYBzJVhUV9BshHrTVEkZKeJPXHNZA0IOkxttrrhzkgGdAlgXnTLv3GIAHsEh87QGNUrooHaEajkoYlFXYxaeO2je+SLp1z57Grr2J4DvwqWaVDrhv+3SAWrMvXgWcgZ10b3a01GuwDX8CWfV/AXr2Sd9lVXPC4ReM6q8XHOYMOG2897rZkrXZY0+WAK6DHHsRr4xJ/NjCTcXstC/gAxuPEBju5xKRb0phNT5xzD7UUW3d8A4p92DZKdSwEAAAAAElFTkSuQmCC",
"mimeType": "image/png",
- "sizes": [
- "24x24"
- ],
+ "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABoUlEQVRIibWUPS+EQRSFz0hsxaIgIZHYllDYiiiUEn6Bn0Dho6Nhf4GPjkYn8ZEgGqFRSNBoVTQKdhEROsk+mrt2svvu7Gutk7yZzJlz77nzztyR/hmulADSkkYk5SQdO+c+QwmAZkkTktolXTjnbkLiDJCniHsgFdCnTFNAHliuJE6bYANoAYaBF+AwYHBkmiGgFdi0HINR4lmrotXjVoG3gMEbsOLN2yzHTIFr8PRZG3s9rs/jo5At0fd6fFk1TfY/X4A14MyqmQrsYNo0pxbzCtwBTZUCUsAh8GHCKaDspnl6ZyZ3FnMA9AR2/BOYBzJVhUV9BshHrTVEkZKeJPXHNZA0IOkxttrrhzkgGdAlgXnTLv3GIAHsEh87QGNUrooHaEajkoYlFXYxaeO2je+SLp1z57Grr2J4DvwqWaVDrhv+3SAWrMvXgWcgZ10b3a01GuwDX8CWfV/AXr2Sd9lVXPC4ReM6q8XHOYMOG2897rZkrXZY0+WAK6DHHsRr4xJ/NjCTcXstC/gAxuPEBju5xKRb0phNT5xzD7UUW3d8A4p92DZKdSwEAAAAAElFTkSuQmCC",
"theme": "dark"
}
- ]
+ ],
+ "inputSchema": {
+ "properties": {
+ "organization": {
+ "description": "Organization to fork to",
+ "type": "string"
+ },
+ "owner": {
+ "description": "Repository owner",
+ "type": "string"
+ },
+ "repo": {
+ "description": "Repository name",
+ "type": "string"
+ }
+ },
+ "required": [
+ "owner",
+ "repo"
+ ],
+ "type": "object"
+ },
+ "name": "fork_repository"
}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/get_code_scanning_alert.snap b/pkg/github/__toolsnaps__/get_code_scanning_alert.snap
index 9e46b960a..2a65aefa6 100644
--- a/pkg/github/__toolsnaps__/get_code_scanning_alert.snap
+++ b/pkg/github/__toolsnaps__/get_code_scanning_alert.snap
@@ -5,26 +5,26 @@
},
"description": "Get details of a specific code scanning alert in a GitHub repository.",
"inputSchema": {
- "type": "object",
- "required": [
- "owner",
- "repo",
- "alertNumber"
- ],
"properties": {
"alertNumber": {
- "type": "number",
- "description": "The number of the alert."
+ "description": "The number of the alert.",
+ "type": "number"
},
"owner": {
- "type": "string",
- "description": "The owner of the repository."
+ "description": "The owner of the repository.",
+ "type": "string"
},
"repo": {
- "type": "string",
- "description": "The name of the repository."
+ "description": "The name of the repository.",
+ "type": "string"
}
- }
+ },
+ "required": [
+ "owner",
+ "repo",
+ "alertNumber"
+ ],
+ "type": "object"
},
"name": "get_code_scanning_alert"
}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/get_commit.snap b/pkg/github/__toolsnaps__/get_commit.snap
index c6b96d5ed..9e2346b59 100644
--- a/pkg/github/__toolsnaps__/get_commit.snap
+++ b/pkg/github/__toolsnaps__/get_commit.snap
@@ -5,42 +5,42 @@
},
"description": "Get details for a commit from a GitHub repository",
"inputSchema": {
- "type": "object",
- "required": [
- "owner",
- "repo",
- "sha"
- ],
"properties": {
"include_diff": {
- "type": "boolean",
+ "default": true,
"description": "Whether to include file diffs and stats in the response. Default is true.",
- "default": true
+ "type": "boolean"
},
"owner": {
- "type": "string",
- "description": "Repository owner"
+ "description": "Repository owner",
+ "type": "string"
},
"page": {
- "type": "number",
"description": "Page number for pagination (min 1)",
- "minimum": 1
+ "minimum": 1,
+ "type": "number"
},
"perPage": {
- "type": "number",
"description": "Results per page for pagination (min 1, max 100)",
+ "maximum": 100,
"minimum": 1,
- "maximum": 100
+ "type": "number"
},
"repo": {
- "type": "string",
- "description": "Repository name"
+ "description": "Repository name",
+ "type": "string"
},
"sha": {
- "type": "string",
- "description": "Commit SHA, branch name, or tag name"
+ "description": "Commit SHA, branch name, or tag name",
+ "type": "string"
}
- }
+ },
+ "required": [
+ "owner",
+ "repo",
+ "sha"
+ ],
+ "type": "object"
},
"name": "get_commit"
}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/get_dependabot_alert.snap b/pkg/github/__toolsnaps__/get_dependabot_alert.snap
index a517809e2..78ff827d2 100644
--- a/pkg/github/__toolsnaps__/get_dependabot_alert.snap
+++ b/pkg/github/__toolsnaps__/get_dependabot_alert.snap
@@ -5,26 +5,26 @@
},
"description": "Get details of a specific dependabot alert in a GitHub repository.",
"inputSchema": {
- "type": "object",
- "required": [
- "owner",
- "repo",
- "alertNumber"
- ],
"properties": {
"alertNumber": {
- "type": "number",
- "description": "The number of the alert."
+ "description": "The number of the alert.",
+ "type": "number"
},
"owner": {
- "type": "string",
- "description": "The owner of the repository."
+ "description": "The owner of the repository.",
+ "type": "string"
},
"repo": {
- "type": "string",
- "description": "The name of the repository."
+ "description": "The name of the repository.",
+ "type": "string"
}
- }
+ },
+ "required": [
+ "owner",
+ "repo",
+ "alertNumber"
+ ],
+ "type": "object"
},
"name": "get_dependabot_alert"
}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/get_discussion.snap b/pkg/github/__toolsnaps__/get_discussion.snap
index feef0f057..b931afe79 100644
--- a/pkg/github/__toolsnaps__/get_discussion.snap
+++ b/pkg/github/__toolsnaps__/get_discussion.snap
@@ -5,26 +5,26 @@
},
"description": "Get a specific discussion by ID",
"inputSchema": {
- "type": "object",
- "required": [
- "owner",
- "repo",
- "discussionNumber"
- ],
"properties": {
"discussionNumber": {
- "type": "number",
- "description": "Discussion Number"
+ "description": "Discussion Number",
+ "type": "number"
},
"owner": {
- "type": "string",
- "description": "Repository owner"
+ "description": "Repository owner",
+ "type": "string"
},
"repo": {
- "type": "string",
- "description": "Repository name"
+ "description": "Repository name",
+ "type": "string"
}
- }
+ },
+ "required": [
+ "owner",
+ "repo",
+ "discussionNumber"
+ ],
+ "type": "object"
},
"name": "get_discussion"
}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/get_discussion_comments.snap b/pkg/github/__toolsnaps__/get_discussion_comments.snap
index 3af5edc8c..f9e609565 100644
--- a/pkg/github/__toolsnaps__/get_discussion_comments.snap
+++ b/pkg/github/__toolsnaps__/get_discussion_comments.snap
@@ -5,36 +5,36 @@
},
"description": "Get comments from a discussion",
"inputSchema": {
- "type": "object",
- "required": [
- "owner",
- "repo",
- "discussionNumber"
- ],
"properties": {
"after": {
- "type": "string",
- "description": "Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs."
+ "description": "Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs.",
+ "type": "string"
},
"discussionNumber": {
- "type": "number",
- "description": "Discussion Number"
+ "description": "Discussion Number",
+ "type": "number"
},
"owner": {
- "type": "string",
- "description": "Repository owner"
+ "description": "Repository owner",
+ "type": "string"
},
"perPage": {
- "type": "number",
"description": "Results per page for pagination (min 1, max 100)",
+ "maximum": 100,
"minimum": 1,
- "maximum": 100
+ "type": "number"
},
"repo": {
- "type": "string",
- "description": "Repository name"
+ "description": "Repository name",
+ "type": "string"
}
- }
+ },
+ "required": [
+ "owner",
+ "repo",
+ "discussionNumber"
+ ],
+ "type": "object"
},
"name": "get_discussion_comments"
}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/get_file_contents.snap b/pkg/github/__toolsnaps__/get_file_contents.snap
index 638452fe7..94b7aeeda 100644
--- a/pkg/github/__toolsnaps__/get_file_contents.snap
+++ b/pkg/github/__toolsnaps__/get_file_contents.snap
@@ -5,34 +5,34 @@
},
"description": "Get the contents of a file or directory from a GitHub repository",
"inputSchema": {
- "type": "object",
- "required": [
- "owner",
- "repo"
- ],
"properties": {
"owner": {
- "type": "string",
- "description": "Repository owner (username or organization)"
+ "description": "Repository owner (username or organization)",
+ "type": "string"
},
"path": {
- "type": "string",
+ "default": "/",
"description": "Path to file/directory",
- "default": "/"
+ "type": "string"
},
"ref": {
- "type": "string",
- "description": "Accepts optional git refs such as `refs/tags/{tag}`, `refs/heads/{branch}` or `refs/pull/{pr_number}/head`"
+ "description": "Accepts optional git refs such as `refs/tags/{tag}`, `refs/heads/{branch}` or `refs/pull/{pr_number}/head`",
+ "type": "string"
},
"repo": {
- "type": "string",
- "description": "Repository name"
+ "description": "Repository name",
+ "type": "string"
},
"sha": {
- "type": "string",
- "description": "Accepts optional commit SHA. If specified, it will be used instead of ref"
+ "description": "Accepts optional commit SHA. If specified, it will be used instead of ref",
+ "type": "string"
}
- }
+ },
+ "required": [
+ "owner",
+ "repo"
+ ],
+ "type": "object"
},
"name": "get_file_contents"
}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/get_gist.snap b/pkg/github/__toolsnaps__/get_gist.snap
index 4d2661822..ef316937f 100644
--- a/pkg/github/__toolsnaps__/get_gist.snap
+++ b/pkg/github/__toolsnaps__/get_gist.snap
@@ -5,16 +5,16 @@
},
"description": "Get gist content of a particular gist, by gist ID",
"inputSchema": {
- "type": "object",
- "required": [
- "gist_id"
- ],
"properties": {
"gist_id": {
- "type": "string",
- "description": "The ID of the gist"
+ "description": "The ID of the gist",
+ "type": "string"
}
- }
+ },
+ "required": [
+ "gist_id"
+ ],
+ "type": "object"
},
"name": "get_gist"
}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/get_global_security_advisory.snap b/pkg/github/__toolsnaps__/get_global_security_advisory.snap
index 18c30425a..97b81d17d 100644
--- a/pkg/github/__toolsnaps__/get_global_security_advisory.snap
+++ b/pkg/github/__toolsnaps__/get_global_security_advisory.snap
@@ -5,16 +5,16 @@
},
"description": "Get a global security advisory",
"inputSchema": {
- "type": "object",
- "required": [
- "ghsaId"
- ],
"properties": {
"ghsaId": {
- "type": "string",
- "description": "GitHub Security Advisory ID (format: GHSA-xxxx-xxxx-xxxx)."
+ "description": "GitHub Security Advisory ID (format: GHSA-xxxx-xxxx-xxxx).",
+ "type": "string"
}
- }
+ },
+ "required": [
+ "ghsaId"
+ ],
+ "type": "object"
},
"name": "get_global_security_advisory"
}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/get_job_logs.snap b/pkg/github/__toolsnaps__/get_job_logs.snap
index 8b2319527..575182c0b 100644
--- a/pkg/github/__toolsnaps__/get_job_logs.snap
+++ b/pkg/github/__toolsnaps__/get_job_logs.snap
@@ -5,42 +5,42 @@
},
"description": "Download logs for a specific workflow job or efficiently get all failed job logs for a workflow run",
"inputSchema": {
- "type": "object",
- "required": [
- "owner",
- "repo"
- ],
"properties": {
"failed_only": {
- "type": "boolean",
- "description": "When true, gets logs for all failed jobs in run_id"
+ "description": "When true, gets logs for all failed jobs in run_id",
+ "type": "boolean"
},
"job_id": {
- "type": "number",
- "description": "The unique identifier of the workflow job (required for single job logs)"
+ "description": "The unique identifier of the workflow job (required for single job logs)",
+ "type": "number"
},
"owner": {
- "type": "string",
- "description": "Repository owner"
+ "description": "Repository owner",
+ "type": "string"
},
"repo": {
- "type": "string",
- "description": "Repository name"
+ "description": "Repository name",
+ "type": "string"
},
"return_content": {
- "type": "boolean",
- "description": "Returns actual log content instead of URLs"
+ "description": "Returns actual log content instead of URLs",
+ "type": "boolean"
},
"run_id": {
- "type": "number",
- "description": "Workflow run ID (required when using failed_only)"
+ "description": "Workflow run ID (required when using failed_only)",
+ "type": "number"
},
"tail_lines": {
- "type": "number",
+ "default": 500,
"description": "Number of lines to return from the end of the log",
- "default": 500
+ "type": "number"
}
- }
+ },
+ "required": [
+ "owner",
+ "repo"
+ ],
+ "type": "object"
},
"name": "get_job_logs"
}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/get_label.snap b/pkg/github/__toolsnaps__/get_label.snap
index 8541044d0..854f048c2 100644
--- a/pkg/github/__toolsnaps__/get_label.snap
+++ b/pkg/github/__toolsnaps__/get_label.snap
@@ -5,26 +5,26 @@
},
"description": "Get a specific label from a repository.",
"inputSchema": {
- "type": "object",
- "required": [
- "owner",
- "repo",
- "name"
- ],
"properties": {
"name": {
- "type": "string",
- "description": "Label name."
+ "description": "Label name.",
+ "type": "string"
},
"owner": {
- "type": "string",
- "description": "Repository owner (username or organization name)"
+ "description": "Repository owner (username or organization name)",
+ "type": "string"
},
"repo": {
- "type": "string",
- "description": "Repository name"
+ "description": "Repository name",
+ "type": "string"
}
- }
+ },
+ "required": [
+ "owner",
+ "repo",
+ "name"
+ ],
+ "type": "object"
},
"name": "get_label"
}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/get_latest_release.snap b/pkg/github/__toolsnaps__/get_latest_release.snap
index 23b551a0f..760d8f812 100644
--- a/pkg/github/__toolsnaps__/get_latest_release.snap
+++ b/pkg/github/__toolsnaps__/get_latest_release.snap
@@ -5,21 +5,21 @@
},
"description": "Get the latest release in a GitHub repository",
"inputSchema": {
- "type": "object",
- "required": [
- "owner",
- "repo"
- ],
"properties": {
"owner": {
- "type": "string",
- "description": "Repository owner"
+ "description": "Repository owner",
+ "type": "string"
},
"repo": {
- "type": "string",
- "description": "Repository name"
+ "description": "Repository name",
+ "type": "string"
}
- }
+ },
+ "required": [
+ "owner",
+ "repo"
+ ],
+ "type": "object"
},
"name": "get_latest_release"
}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/get_me.snap b/pkg/github/__toolsnaps__/get_me.snap
index e6d02929f..4d7d2573b 100644
--- a/pkg/github/__toolsnaps__/get_me.snap
+++ b/pkg/github/__toolsnaps__/get_me.snap
@@ -5,8 +5,8 @@
},
"description": "Get details of the authenticated GitHub user. Use this when a request is about the user's own profile for GitHub. Or when information is missing to build other tool calls.",
"inputSchema": {
- "type": "object",
- "properties": {}
+ "properties": {},
+ "type": "object"
},
"name": "get_me"
}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/get_notification_details.snap b/pkg/github/__toolsnaps__/get_notification_details.snap
index de197f2b1..48842229f 100644
--- a/pkg/github/__toolsnaps__/get_notification_details.snap
+++ b/pkg/github/__toolsnaps__/get_notification_details.snap
@@ -5,16 +5,16 @@
},
"description": "Get detailed information for a specific GitHub notification, always call this tool when the user asks for details about a specific notification, if you don't know the ID list notifications first.",
"inputSchema": {
- "type": "object",
- "required": [
- "notificationID"
- ],
"properties": {
"notificationID": {
- "type": "string",
- "description": "The ID of the notification"
+ "description": "The ID of the notification",
+ "type": "string"
}
- }
+ },
+ "required": [
+ "notificationID"
+ ],
+ "type": "object"
},
"name": "get_notification_details"
}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/get_project.snap b/pkg/github/__toolsnaps__/get_project.snap
index 8194b7358..6ff320fe8 100644
--- a/pkg/github/__toolsnaps__/get_project.snap
+++ b/pkg/github/__toolsnaps__/get_project.snap
@@ -5,30 +5,30 @@
},
"description": "Get Project for a user or org",
"inputSchema": {
- "type": "object",
- "required": [
- "project_number",
- "owner_type",
- "owner"
- ],
"properties": {
"owner": {
- "type": "string",
- "description": "If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive."
+ "description": "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.",
+ "type": "string"
},
"owner_type": {
- "type": "string",
"description": "Owner type",
"enum": [
"user",
"org"
- ]
+ ],
+ "type": "string"
},
"project_number": {
- "type": "number",
- "description": "The project's number"
+ "description": "The project's number",
+ "type": "number"
}
- }
+ },
+ "required": [
+ "project_number",
+ "owner_type",
+ "owner"
+ ],
+ "type": "object"
},
"name": "get_project"
}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/get_project_field.snap b/pkg/github/__toolsnaps__/get_project_field.snap
index 0df557a03..9d884a20f 100644
--- a/pkg/github/__toolsnaps__/get_project_field.snap
+++ b/pkg/github/__toolsnaps__/get_project_field.snap
@@ -5,35 +5,35 @@
},
"description": "Get Project field for a user or org",
"inputSchema": {
- "type": "object",
- "required": [
- "owner_type",
- "owner",
- "project_number",
- "field_id"
- ],
"properties": {
"field_id": {
- "type": "number",
- "description": "The field's id."
+ "description": "The field's id.",
+ "type": "number"
},
"owner": {
- "type": "string",
- "description": "If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive."
+ "description": "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.",
+ "type": "string"
},
"owner_type": {
- "type": "string",
"description": "Owner type",
"enum": [
"user",
"org"
- ]
+ ],
+ "type": "string"
},
"project_number": {
- "type": "number",
- "description": "The project's number."
+ "description": "The project's number.",
+ "type": "number"
}
- }
+ },
+ "required": [
+ "owner_type",
+ "owner",
+ "project_number",
+ "field_id"
+ ],
+ "type": "object"
},
"name": "get_project_field"
}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/get_project_item.snap b/pkg/github/__toolsnaps__/get_project_item.snap
index d77c49c1e..202bcc53e 100644
--- a/pkg/github/__toolsnaps__/get_project_item.snap
+++ b/pkg/github/__toolsnaps__/get_project_item.snap
@@ -5,42 +5,42 @@
},
"description": "Get a specific Project item for a user or org",
"inputSchema": {
- "type": "object",
- "required": [
- "owner_type",
- "owner",
- "project_number",
- "item_id"
- ],
"properties": {
"fields": {
- "type": "array",
"description": "Specific list of field IDs to include in the response (e.g. [\"102589\", \"985201\", \"169875\"]). If not provided, only the title field is included.",
"items": {
"type": "string"
- }
+ },
+ "type": "array"
},
"item_id": {
- "type": "number",
- "description": "The item's ID."
+ "description": "The item's ID.",
+ "type": "number"
},
"owner": {
- "type": "string",
- "description": "If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive."
+ "description": "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.",
+ "type": "string"
},
"owner_type": {
- "type": "string",
"description": "Owner type",
"enum": [
"user",
"org"
- ]
+ ],
+ "type": "string"
},
"project_number": {
- "type": "number",
- "description": "The project's number."
+ "description": "The project's number.",
+ "type": "number"
}
- }
+ },
+ "required": [
+ "owner_type",
+ "owner",
+ "project_number",
+ "item_id"
+ ],
+ "type": "object"
},
"name": "get_project_item"
}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/get_release_by_tag.snap b/pkg/github/__toolsnaps__/get_release_by_tag.snap
index 77f19488c..6e6d30e98 100644
--- a/pkg/github/__toolsnaps__/get_release_by_tag.snap
+++ b/pkg/github/__toolsnaps__/get_release_by_tag.snap
@@ -5,26 +5,26 @@
},
"description": "Get a specific release by its tag name in a GitHub repository",
"inputSchema": {
- "type": "object",
- "required": [
- "owner",
- "repo",
- "tag"
- ],
"properties": {
"owner": {
- "type": "string",
- "description": "Repository owner"
+ "description": "Repository owner",
+ "type": "string"
},
"repo": {
- "type": "string",
- "description": "Repository name"
+ "description": "Repository name",
+ "type": "string"
},
"tag": {
- "type": "string",
- "description": "Tag name (e.g., 'v1.0.0')"
+ "description": "Tag name (e.g., 'v1.0.0')",
+ "type": "string"
}
- }
+ },
+ "required": [
+ "owner",
+ "repo",
+ "tag"
+ ],
+ "type": "object"
},
"name": "get_release_by_tag"
}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/get_repository_tree.snap b/pkg/github/__toolsnaps__/get_repository_tree.snap
index 882462883..c810d1e20 100644
--- a/pkg/github/__toolsnaps__/get_repository_tree.snap
+++ b/pkg/github/__toolsnaps__/get_repository_tree.snap
@@ -5,34 +5,34 @@
},
"description": "Get the tree structure (files and directories) of a GitHub repository at a specific ref or SHA",
"inputSchema": {
- "type": "object",
- "required": [
- "owner",
- "repo"
- ],
"properties": {
"owner": {
- "type": "string",
- "description": "Repository owner (username or organization)"
+ "description": "Repository owner (username or organization)",
+ "type": "string"
},
"path_filter": {
- "type": "string",
- "description": "Optional path prefix to filter the tree results (e.g., 'src/' to only show files in the src directory)"
+ "description": "Optional path prefix to filter the tree results (e.g., 'src/' to only show files in the src directory)",
+ "type": "string"
},
"recursive": {
- "type": "boolean",
+ "default": false,
"description": "Setting this parameter to true returns the objects or subtrees referenced by the tree. Default is false",
- "default": false
+ "type": "boolean"
},
"repo": {
- "type": "string",
- "description": "Repository name"
+ "description": "Repository name",
+ "type": "string"
},
"tree_sha": {
- "type": "string",
- "description": "The SHA1 value or ref (branch or tag) name of the tree. Defaults to the repository's default branch"
+ "description": "The SHA1 value or ref (branch or tag) name of the tree. Defaults to the repository's default branch",
+ "type": "string"
}
- }
+ },
+ "required": [
+ "owner",
+ "repo"
+ ],
+ "type": "object"
},
"name": "get_repository_tree"
}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/get_secret_scanning_alert.snap b/pkg/github/__toolsnaps__/get_secret_scanning_alert.snap
index 4d55011da..2789cfbab 100644
--- a/pkg/github/__toolsnaps__/get_secret_scanning_alert.snap
+++ b/pkg/github/__toolsnaps__/get_secret_scanning_alert.snap
@@ -5,26 +5,26 @@
},
"description": "Get details of a specific secret scanning alert in a GitHub repository.",
"inputSchema": {
- "type": "object",
- "required": [
- "owner",
- "repo",
- "alertNumber"
- ],
"properties": {
"alertNumber": {
- "type": "number",
- "description": "The number of the alert."
+ "description": "The number of the alert.",
+ "type": "number"
},
"owner": {
- "type": "string",
- "description": "The owner of the repository."
+ "description": "The owner of the repository.",
+ "type": "string"
},
"repo": {
- "type": "string",
- "description": "The name of the repository."
+ "description": "The name of the repository.",
+ "type": "string"
}
- }
+ },
+ "required": [
+ "owner",
+ "repo",
+ "alertNumber"
+ ],
+ "type": "object"
},
"name": "get_secret_scanning_alert"
}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/get_tag.snap b/pkg/github/__toolsnaps__/get_tag.snap
index e33f5c2e4..126e8a999 100644
--- a/pkg/github/__toolsnaps__/get_tag.snap
+++ b/pkg/github/__toolsnaps__/get_tag.snap
@@ -5,26 +5,26 @@
},
"description": "Get details about a specific git tag in a GitHub repository",
"inputSchema": {
- "type": "object",
- "required": [
- "owner",
- "repo",
- "tag"
- ],
"properties": {
"owner": {
- "type": "string",
- "description": "Repository owner"
+ "description": "Repository owner",
+ "type": "string"
},
"repo": {
- "type": "string",
- "description": "Repository name"
+ "description": "Repository name",
+ "type": "string"
},
"tag": {
- "type": "string",
- "description": "Tag name"
+ "description": "Tag name",
+ "type": "string"
}
- }
+ },
+ "required": [
+ "owner",
+ "repo",
+ "tag"
+ ],
+ "type": "object"
},
"name": "get_tag"
}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/get_team_members.snap b/pkg/github/__toolsnaps__/get_team_members.snap
index 5b7f090fe..4cde7237c 100644
--- a/pkg/github/__toolsnaps__/get_team_members.snap
+++ b/pkg/github/__toolsnaps__/get_team_members.snap
@@ -5,21 +5,21 @@
},
"description": "Get member usernames of a specific team in an organization. Limited to organizations accessible with current credentials",
"inputSchema": {
- "type": "object",
- "required": [
- "org",
- "team_slug"
- ],
"properties": {
"org": {
- "type": "string",
- "description": "Organization login (owner) that contains the team."
+ "description": "Organization login (owner) that contains the team.",
+ "type": "string"
},
"team_slug": {
- "type": "string",
- "description": "Team slug"
+ "description": "Team slug",
+ "type": "string"
}
- }
+ },
+ "required": [
+ "org",
+ "team_slug"
+ ],
+ "type": "object"
},
"name": "get_team_members"
}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/get_teams.snap b/pkg/github/__toolsnaps__/get_teams.snap
index 595dd262d..946364bad 100644
--- a/pkg/github/__toolsnaps__/get_teams.snap
+++ b/pkg/github/__toolsnaps__/get_teams.snap
@@ -5,13 +5,13 @@
},
"description": "Get details of the teams the user is a member of. Limited to organizations accessible with current credentials",
"inputSchema": {
- "type": "object",
"properties": {
"user": {
- "type": "string",
- "description": "Username to get teams for. If not provided, uses the authenticated user."
+ "description": "Username to get teams for. If not provided, uses the authenticated user.",
+ "type": "string"
}
- }
+ },
+ "type": "object"
},
"name": "get_teams"
}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/get_workflow_run.snap b/pkg/github/__toolsnaps__/get_workflow_run.snap
index 37921ffad..e58ea0ba2 100644
--- a/pkg/github/__toolsnaps__/get_workflow_run.snap
+++ b/pkg/github/__toolsnaps__/get_workflow_run.snap
@@ -5,26 +5,26 @@
},
"description": "Get details of a specific workflow run",
"inputSchema": {
- "type": "object",
- "required": [
- "owner",
- "repo",
- "run_id"
- ],
"properties": {
"owner": {
- "type": "string",
- "description": "Repository owner"
+ "description": "Repository owner",
+ "type": "string"
},
"repo": {
- "type": "string",
- "description": "Repository name"
+ "description": "Repository name",
+ "type": "string"
},
"run_id": {
- "type": "number",
- "description": "The unique identifier of the workflow run"
+ "description": "The unique identifier of the workflow run",
+ "type": "number"
}
- }
+ },
+ "required": [
+ "owner",
+ "repo",
+ "run_id"
+ ],
+ "type": "object"
},
"name": "get_workflow_run"
}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/get_workflow_run_logs.snap b/pkg/github/__toolsnaps__/get_workflow_run_logs.snap
index 77fb619b7..8e76fbfc3 100644
--- a/pkg/github/__toolsnaps__/get_workflow_run_logs.snap
+++ b/pkg/github/__toolsnaps__/get_workflow_run_logs.snap
@@ -5,26 +5,26 @@
},
"description": "Download logs for a specific workflow run (EXPENSIVE: downloads ALL logs as ZIP. Consider using get_job_logs with failed_only=true for debugging failed jobs)",
"inputSchema": {
- "type": "object",
- "required": [
- "owner",
- "repo",
- "run_id"
- ],
"properties": {
"owner": {
- "type": "string",
- "description": "Repository owner"
+ "description": "Repository owner",
+ "type": "string"
},
"repo": {
- "type": "string",
- "description": "Repository name"
+ "description": "Repository name",
+ "type": "string"
},
"run_id": {
- "type": "number",
- "description": "The unique identifier of the workflow run"
+ "description": "The unique identifier of the workflow run",
+ "type": "number"
}
- }
+ },
+ "required": [
+ "owner",
+ "repo",
+ "run_id"
+ ],
+ "type": "object"
},
"name": "get_workflow_run_logs"
}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/get_workflow_run_usage.snap b/pkg/github/__toolsnaps__/get_workflow_run_usage.snap
index c9fe49f96..40069b836 100644
--- a/pkg/github/__toolsnaps__/get_workflow_run_usage.snap
+++ b/pkg/github/__toolsnaps__/get_workflow_run_usage.snap
@@ -5,26 +5,26 @@
},
"description": "Get usage metrics for a workflow run",
"inputSchema": {
- "type": "object",
- "required": [
- "owner",
- "repo",
- "run_id"
- ],
"properties": {
"owner": {
- "type": "string",
- "description": "Repository owner"
+ "description": "Repository owner",
+ "type": "string"
},
"repo": {
- "type": "string",
- "description": "Repository name"
+ "description": "Repository name",
+ "type": "string"
},
"run_id": {
- "type": "number",
- "description": "The unique identifier of the workflow run"
+ "description": "The unique identifier of the workflow run",
+ "type": "number"
}
- }
+ },
+ "required": [
+ "owner",
+ "repo",
+ "run_id"
+ ],
+ "type": "object"
},
"name": "get_workflow_run_usage"
}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/issue_read.snap b/pkg/github/__toolsnaps__/issue_read.snap
index c6a9e7306..21aa361f5 100644
--- a/pkg/github/__toolsnaps__/issue_read.snap
+++ b/pkg/github/__toolsnaps__/issue_read.snap
@@ -5,48 +5,48 @@
},
"description": "Get information about a specific issue in a GitHub repository.",
"inputSchema": {
- "type": "object",
- "required": [
- "method",
- "owner",
- "repo",
- "issue_number"
- ],
"properties": {
"issue_number": {
- "type": "number",
- "description": "The number of the issue"
+ "description": "The number of the issue",
+ "type": "number"
},
"method": {
- "type": "string",
"description": "The read operation to perform on a single issue.\nOptions are:\n1. get - Get details of a specific issue.\n2. get_comments - Get issue comments.\n3. get_sub_issues - Get sub-issues of the issue.\n4. get_labels - Get labels assigned to the issue.\n",
"enum": [
"get",
"get_comments",
"get_sub_issues",
"get_labels"
- ]
+ ],
+ "type": "string"
},
"owner": {
- "type": "string",
- "description": "The owner of the repository"
+ "description": "The owner of the repository",
+ "type": "string"
},
"page": {
- "type": "number",
"description": "Page number for pagination (min 1)",
- "minimum": 1
+ "minimum": 1,
+ "type": "number"
},
"perPage": {
- "type": "number",
"description": "Results per page for pagination (min 1, max 100)",
+ "maximum": 100,
"minimum": 1,
- "maximum": 100
+ "type": "number"
},
"repo": {
- "type": "string",
- "description": "The name of the repository"
+ "description": "The name of the repository",
+ "type": "string"
}
- }
+ },
+ "required": [
+ "method",
+ "owner",
+ "repo",
+ "issue_number"
+ ],
+ "type": "object"
},
"name": "issue_read"
}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/issue_write.snap b/pkg/github/__toolsnaps__/issue_write.snap
index 8c6634a02..4512eb614 100644
--- a/pkg/github/__toolsnaps__/issue_write.snap
+++ b/pkg/github/__toolsnaps__/issue_write.snap
@@ -4,85 +4,85 @@
},
"description": "Create a new or update an existing issue in a GitHub repository.",
"inputSchema": {
- "type": "object",
- "required": [
- "method",
- "owner",
- "repo"
- ],
"properties": {
"assignees": {
- "type": "array",
"description": "Usernames to assign to this issue",
"items": {
"type": "string"
- }
+ },
+ "type": "array"
},
"body": {
- "type": "string",
- "description": "Issue body content"
+ "description": "Issue body content",
+ "type": "string"
},
"duplicate_of": {
- "type": "number",
- "description": "Issue number that this issue is a duplicate of. Only used when state_reason is 'duplicate'."
+ "description": "Issue number that this issue is a duplicate of. Only used when state_reason is 'duplicate'.",
+ "type": "number"
},
"issue_number": {
- "type": "number",
- "description": "Issue number to update"
+ "description": "Issue number to update",
+ "type": "number"
},
"labels": {
- "type": "array",
"description": "Labels to apply to this issue",
"items": {
"type": "string"
- }
+ },
+ "type": "array"
},
"method": {
- "type": "string",
"description": "Write operation to perform on a single issue.\nOptions are:\n- 'create' - creates a new issue.\n- 'update' - updates an existing issue.\n",
"enum": [
"create",
"update"
- ]
+ ],
+ "type": "string"
},
"milestone": {
- "type": "number",
- "description": "Milestone number"
+ "description": "Milestone number",
+ "type": "number"
},
"owner": {
- "type": "string",
- "description": "Repository owner"
+ "description": "Repository owner",
+ "type": "string"
},
"repo": {
- "type": "string",
- "description": "Repository name"
+ "description": "Repository name",
+ "type": "string"
},
"state": {
- "type": "string",
"description": "New state",
"enum": [
"open",
"closed"
- ]
+ ],
+ "type": "string"
},
"state_reason": {
- "type": "string",
"description": "Reason for the state change. Ignored unless state is changed.",
"enum": [
"completed",
"not_planned",
"duplicate"
- ]
+ ],
+ "type": "string"
},
"title": {
- "type": "string",
- "description": "Issue title"
+ "description": "Issue title",
+ "type": "string"
},
"type": {
- "type": "string",
- "description": "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."
+ "description": "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.",
+ "type": "string"
}
- }
+ },
+ "required": [
+ "method",
+ "owner",
+ "repo"
+ ],
+ "type": "object"
},
"name": "issue_write"
}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/label_write.snap b/pkg/github/__toolsnaps__/label_write.snap
index 879817442..f0aca8cc9 100644
--- a/pkg/github/__toolsnaps__/label_write.snap
+++ b/pkg/github/__toolsnaps__/label_write.snap
@@ -4,48 +4,48 @@
},
"description": "Perform write operations on repository labels. To set labels on issues, use the 'update_issue' tool.",
"inputSchema": {
- "type": "object",
- "required": [
- "method",
- "owner",
- "repo",
- "name"
- ],
"properties": {
"color": {
- "type": "string",
- "description": "Label color as 6-character hex code without '#' prefix (e.g., 'f29513'). Required for 'create', optional for 'update'."
+ "description": "Label color as 6-character hex code without '#' prefix (e.g., 'f29513'). Required for 'create', optional for 'update'.",
+ "type": "string"
},
"description": {
- "type": "string",
- "description": "Label description text. Optional for 'create' and 'update'."
+ "description": "Label description text. Optional for 'create' and 'update'.",
+ "type": "string"
},
"method": {
- "type": "string",
"description": "Operation to perform: 'create', 'update', or 'delete'",
"enum": [
"create",
"update",
"delete"
- ]
+ ],
+ "type": "string"
},
"name": {
- "type": "string",
- "description": "Label name - required for all operations"
+ "description": "Label name - required for all operations",
+ "type": "string"
},
"new_name": {
- "type": "string",
- "description": "New name for the label (used only with 'update' method to rename)"
+ "description": "New name for the label (used only with 'update' method to rename)",
+ "type": "string"
},
"owner": {
- "type": "string",
- "description": "Repository owner (username or organization name)"
+ "description": "Repository owner (username or organization name)",
+ "type": "string"
},
"repo": {
- "type": "string",
- "description": "Repository name"
+ "description": "Repository name",
+ "type": "string"
}
- }
+ },
+ "required": [
+ "method",
+ "owner",
+ "repo",
+ "name"
+ ],
+ "type": "object"
},
"name": "label_write"
}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/list_branches.snap b/pkg/github/__toolsnaps__/list_branches.snap
index b589c9b7e..883a6fffc 100644
--- a/pkg/github/__toolsnaps__/list_branches.snap
+++ b/pkg/github/__toolsnaps__/list_branches.snap
@@ -5,32 +5,32 @@
},
"description": "List branches in a GitHub repository",
"inputSchema": {
- "type": "object",
- "required": [
- "owner",
- "repo"
- ],
"properties": {
"owner": {
- "type": "string",
- "description": "Repository owner"
+ "description": "Repository owner",
+ "type": "string"
},
"page": {
- "type": "number",
"description": "Page number for pagination (min 1)",
- "minimum": 1
+ "minimum": 1,
+ "type": "number"
},
"perPage": {
- "type": "number",
"description": "Results per page for pagination (min 1, max 100)",
+ "maximum": 100,
"minimum": 1,
- "maximum": 100
+ "type": "number"
},
"repo": {
- "type": "string",
- "description": "Repository name"
+ "description": "Repository name",
+ "type": "string"
}
- }
+ },
+ "required": [
+ "owner",
+ "repo"
+ ],
+ "type": "object"
},
"name": "list_branches"
}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/list_code_scanning_alerts.snap b/pkg/github/__toolsnaps__/list_code_scanning_alerts.snap
index 6f2a4e342..5b7d79ef4 100644
--- a/pkg/github/__toolsnaps__/list_code_scanning_alerts.snap
+++ b/pkg/github/__toolsnaps__/list_code_scanning_alerts.snap
@@ -5,26 +5,20 @@
},
"description": "List code scanning alerts in a GitHub repository.",
"inputSchema": {
- "type": "object",
- "required": [
- "owner",
- "repo"
- ],
"properties": {
"owner": {
- "type": "string",
- "description": "The owner of the repository."
+ "description": "The owner of the repository.",
+ "type": "string"
},
"ref": {
- "type": "string",
- "description": "The Git reference for the results you want to list."
+ "description": "The Git reference for the results you want to list.",
+ "type": "string"
},
"repo": {
- "type": "string",
- "description": "The name of the repository."
+ "description": "The name of the repository.",
+ "type": "string"
},
"severity": {
- "type": "string",
"description": "Filter code scanning alerts by severity",
"enum": [
"critical",
@@ -34,24 +28,30 @@
"warning",
"note",
"error"
- ]
+ ],
+ "type": "string"
},
"state": {
- "type": "string",
- "description": "Filter code scanning alerts by state. Defaults to open",
"default": "open",
+ "description": "Filter code scanning alerts by state. Defaults to open",
"enum": [
"open",
"closed",
"dismissed",
"fixed"
- ]
+ ],
+ "type": "string"
},
"tool_name": {
- "type": "string",
- "description": "The name of the tool used for code scanning."
+ "description": "The name of the tool used for code scanning.",
+ "type": "string"
}
- }
+ },
+ "required": [
+ "owner",
+ "repo"
+ ],
+ "type": "object"
},
"name": "list_code_scanning_alerts"
}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/list_commits.snap b/pkg/github/__toolsnaps__/list_commits.snap
index bd67602ed..38b63736f 100644
--- a/pkg/github/__toolsnaps__/list_commits.snap
+++ b/pkg/github/__toolsnaps__/list_commits.snap
@@ -5,40 +5,40 @@
},
"description": "Get list of commits of a branch in a GitHub repository. Returns at least 30 results per page by default, but can return more if specified using the perPage parameter (up to 100).",
"inputSchema": {
- "type": "object",
- "required": [
- "owner",
- "repo"
- ],
"properties": {
"author": {
- "type": "string",
- "description": "Author username or email address to filter commits by"
+ "description": "Author username or email address to filter commits by",
+ "type": "string"
},
"owner": {
- "type": "string",
- "description": "Repository owner"
+ "description": "Repository owner",
+ "type": "string"
},
"page": {
- "type": "number",
"description": "Page number for pagination (min 1)",
- "minimum": 1
+ "minimum": 1,
+ "type": "number"
},
"perPage": {
- "type": "number",
"description": "Results per page for pagination (min 1, max 100)",
+ "maximum": 100,
"minimum": 1,
- "maximum": 100
+ "type": "number"
},
"repo": {
- "type": "string",
- "description": "Repository name"
+ "description": "Repository name",
+ "type": "string"
},
"sha": {
- "type": "string",
- "description": "Commit SHA, branch or tag name to list commits of. If not provided, uses the default branch of the repository. If a commit SHA is provided, will list commits up to that SHA."
+ "description": "Commit SHA, branch or tag name to list commits of. If not provided, uses the default branch of the repository. If a commit SHA is provided, will list commits up to that SHA.",
+ "type": "string"
}
- }
+ },
+ "required": [
+ "owner",
+ "repo"
+ ],
+ "type": "object"
},
"name": "list_commits"
}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/list_dependabot_alerts.snap b/pkg/github/__toolsnaps__/list_dependabot_alerts.snap
index d96d3972c..83f725987 100644
--- a/pkg/github/__toolsnaps__/list_dependabot_alerts.snap
+++ b/pkg/github/__toolsnaps__/list_dependabot_alerts.snap
@@ -5,42 +5,42 @@
},
"description": "List dependabot alerts in a GitHub repository.",
"inputSchema": {
- "type": "object",
- "required": [
- "owner",
- "repo"
- ],
"properties": {
"owner": {
- "type": "string",
- "description": "The owner of the repository."
+ "description": "The owner of the repository.",
+ "type": "string"
},
"repo": {
- "type": "string",
- "description": "The name of the repository."
+ "description": "The name of the repository.",
+ "type": "string"
},
"severity": {
- "type": "string",
"description": "Filter dependabot alerts by severity",
"enum": [
"low",
"medium",
"high",
"critical"
- ]
+ ],
+ "type": "string"
},
"state": {
- "type": "string",
- "description": "Filter dependabot alerts by state. Defaults to open",
"default": "open",
+ "description": "Filter dependabot alerts by state. Defaults to open",
"enum": [
"open",
"fixed",
"dismissed",
"auto_dismissed"
- ]
+ ],
+ "type": "string"
}
- }
+ },
+ "required": [
+ "owner",
+ "repo"
+ ],
+ "type": "object"
},
"name": "list_dependabot_alerts"
}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/list_discussion_categories.snap b/pkg/github/__toolsnaps__/list_discussion_categories.snap
index 888ebbdca..c46b75f84 100644
--- a/pkg/github/__toolsnaps__/list_discussion_categories.snap
+++ b/pkg/github/__toolsnaps__/list_discussion_categories.snap
@@ -5,20 +5,20 @@
},
"description": "List discussion categories with their id and name, for a repository or organisation.",
"inputSchema": {
- "type": "object",
- "required": [
- "owner"
- ],
"properties": {
"owner": {
- "type": "string",
- "description": "Repository owner"
+ "description": "Repository owner",
+ "type": "string"
},
"repo": {
- "type": "string",
- "description": "Repository name. If not provided, discussion categories will be queried at the organisation level."
+ "description": "Repository name. If not provided, discussion categories will be queried at the organisation level.",
+ "type": "string"
}
- }
+ },
+ "required": [
+ "owner"
+ ],
+ "type": "object"
},
"name": "list_discussion_categories"
}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/list_discussions.snap b/pkg/github/__toolsnaps__/list_discussions.snap
index 95a8bebf5..42be76933 100644
--- a/pkg/github/__toolsnaps__/list_discussions.snap
+++ b/pkg/github/__toolsnaps__/list_discussions.snap
@@ -5,50 +5,50 @@
},
"description": "List discussions for a repository or organisation.",
"inputSchema": {
- "type": "object",
- "required": [
- "owner"
- ],
"properties": {
"after": {
- "type": "string",
- "description": "Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs."
+ "description": "Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs.",
+ "type": "string"
},
"category": {
- "type": "string",
- "description": "Optional filter by discussion category ID. If provided, only discussions with this category are listed."
+ "description": "Optional filter by discussion category ID. If provided, only discussions with this category are listed.",
+ "type": "string"
},
"direction": {
- "type": "string",
"description": "Order direction.",
"enum": [
"ASC",
"DESC"
- ]
+ ],
+ "type": "string"
},
"orderBy": {
- "type": "string",
"description": "Order discussions by field. If provided, the 'direction' also needs to be provided.",
"enum": [
"CREATED_AT",
"UPDATED_AT"
- ]
+ ],
+ "type": "string"
},
"owner": {
- "type": "string",
- "description": "Repository owner"
+ "description": "Repository owner",
+ "type": "string"
},
"perPage": {
- "type": "number",
"description": "Results per page for pagination (min 1, max 100)",
+ "maximum": 100,
"minimum": 1,
- "maximum": 100
+ "type": "number"
},
"repo": {
- "type": "string",
- "description": "Repository name. If not provided, discussions will be queried at the organisation level."
+ "description": "Repository name. If not provided, discussions will be queried at the organisation level.",
+ "type": "string"
}
- }
+ },
+ "required": [
+ "owner"
+ ],
+ "type": "object"
},
"name": "list_discussions"
}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/list_gists.snap b/pkg/github/__toolsnaps__/list_gists.snap
index 834b45205..397417303 100644
--- a/pkg/github/__toolsnaps__/list_gists.snap
+++ b/pkg/github/__toolsnaps__/list_gists.snap
@@ -5,28 +5,28 @@
},
"description": "List gists for a user",
"inputSchema": {
- "type": "object",
"properties": {
"page": {
- "type": "number",
"description": "Page number for pagination (min 1)",
- "minimum": 1
+ "minimum": 1,
+ "type": "number"
},
"perPage": {
- "type": "number",
"description": "Results per page for pagination (min 1, max 100)",
+ "maximum": 100,
"minimum": 1,
- "maximum": 100
+ "type": "number"
},
"since": {
- "type": "string",
- "description": "Only gists updated after this time (ISO 8601 timestamp)"
+ "description": "Only gists updated after this time (ISO 8601 timestamp)",
+ "type": "string"
},
"username": {
- "type": "string",
- "description": "GitHub username (omit for authenticated user's gists)"
+ "description": "GitHub username (omit for authenticated user's gists)",
+ "type": "string"
}
- }
+ },
+ "type": "object"
},
"name": "list_gists"
}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/list_global_security_advisories.snap b/pkg/github/__toolsnaps__/list_global_security_advisories.snap
index fd9fa78c5..f714f4782 100644
--- a/pkg/github/__toolsnaps__/list_global_security_advisories.snap
+++ b/pkg/github/__toolsnaps__/list_global_security_advisories.snap
@@ -5,25 +5,23 @@
},
"description": "List global security advisories from GitHub.",
"inputSchema": {
- "type": "object",
"properties": {
"affects": {
- "type": "string",
- "description": "Filter advisories by affected package or version (e.g. \"package1,package2@1.0.0\")."
+ "description": "Filter advisories by affected package or version (e.g. \"package1,package2@1.0.0\").",
+ "type": "string"
},
"cveId": {
- "type": "string",
- "description": "Filter by CVE ID."
+ "description": "Filter by CVE ID.",
+ "type": "string"
},
"cwes": {
- "type": "array",
"description": "Filter by Common Weakness Enumeration IDs (e.g. [\"79\", \"284\", \"22\"]).",
"items": {
"type": "string"
- }
+ },
+ "type": "array"
},
"ecosystem": {
- "type": "string",
"description": "Filter by package ecosystem.",
"enum": [
"actions",
@@ -38,26 +36,26 @@
"pub",
"rubygems",
"rust"
- ]
+ ],
+ "type": "string"
},
"ghsaId": {
- "type": "string",
- "description": "Filter by GitHub Security Advisory ID (format: GHSA-xxxx-xxxx-xxxx)."
+ "description": "Filter by GitHub Security Advisory ID (format: GHSA-xxxx-xxxx-xxxx).",
+ "type": "string"
},
"isWithdrawn": {
- "type": "boolean",
- "description": "Whether to only return withdrawn advisories."
+ "description": "Whether to only return withdrawn advisories.",
+ "type": "boolean"
},
"modified": {
- "type": "string",
- "description": "Filter by publish or update date or date range (ISO 8601 date or range)."
+ "description": "Filter by publish or update date or date range (ISO 8601 date or range).",
+ "type": "string"
},
"published": {
- "type": "string",
- "description": "Filter by publish date or date range (ISO 8601 date or range)."
+ "description": "Filter by publish date or date range (ISO 8601 date or range).",
+ "type": "string"
},
"severity": {
- "type": "string",
"description": "Filter by severity.",
"enum": [
"unknown",
@@ -65,23 +63,25 @@
"medium",
"high",
"critical"
- ]
+ ],
+ "type": "string"
},
"type": {
- "type": "string",
- "description": "Advisory type.",
"default": "reviewed",
+ "description": "Advisory type.",
"enum": [
"reviewed",
"malware",
"unreviewed"
- ]
+ ],
+ "type": "string"
},
"updated": {
- "type": "string",
- "description": "Filter by update date or date range (ISO 8601 date or range)."
+ "description": "Filter by update date or date range (ISO 8601 date or range).",
+ "type": "string"
}
- }
+ },
+ "type": "object"
},
"name": "list_global_security_advisories"
}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/list_issue_types.snap b/pkg/github/__toolsnaps__/list_issue_types.snap
index b17dcc54f..f1f1377a8 100644
--- a/pkg/github/__toolsnaps__/list_issue_types.snap
+++ b/pkg/github/__toolsnaps__/list_issue_types.snap
@@ -5,16 +5,16 @@
},
"description": "List supported issue types for repository owner (organization).",
"inputSchema": {
- "type": "object",
- "required": [
- "owner"
- ],
"properties": {
"owner": {
- "type": "string",
- "description": "The organization owner of the repository"
+ "description": "The organization owner of the repository",
+ "type": "string"
}
- }
+ },
+ "required": [
+ "owner"
+ ],
+ "type": "object"
},
"name": "list_issue_types"
}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/list_issues.snap b/pkg/github/__toolsnaps__/list_issues.snap
index 9d6b55586..a4be59bb0 100644
--- a/pkg/github/__toolsnaps__/list_issues.snap
+++ b/pkg/github/__toolsnaps__/list_issues.snap
@@ -5,67 +5,67 @@
},
"description": "List issues in a GitHub repository. For pagination, use the 'endCursor' from the previous response's 'pageInfo' in the 'after' parameter.",
"inputSchema": {
- "type": "object",
- "required": [
- "owner",
- "repo"
- ],
"properties": {
"after": {
- "type": "string",
- "description": "Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs."
+ "description": "Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs.",
+ "type": "string"
},
"direction": {
- "type": "string",
"description": "Order direction. If provided, the 'orderBy' also needs to be provided.",
"enum": [
"ASC",
"DESC"
- ]
+ ],
+ "type": "string"
},
"labels": {
- "type": "array",
"description": "Filter by labels",
"items": {
"type": "string"
- }
+ },
+ "type": "array"
},
"orderBy": {
- "type": "string",
"description": "Order issues by field. If provided, the 'direction' also needs to be provided.",
"enum": [
"CREATED_AT",
"UPDATED_AT",
"COMMENTS"
- ]
+ ],
+ "type": "string"
},
"owner": {
- "type": "string",
- "description": "Repository owner"
+ "description": "Repository owner",
+ "type": "string"
},
"perPage": {
- "type": "number",
"description": "Results per page for pagination (min 1, max 100)",
+ "maximum": 100,
"minimum": 1,
- "maximum": 100
+ "type": "number"
},
"repo": {
- "type": "string",
- "description": "Repository name"
+ "description": "Repository name",
+ "type": "string"
},
"since": {
- "type": "string",
- "description": "Filter by date (ISO 8601 timestamp)"
+ "description": "Filter by date (ISO 8601 timestamp)",
+ "type": "string"
},
"state": {
- "type": "string",
"description": "Filter by state, by default both open and closed issues are returned when not provided",
"enum": [
"OPEN",
"CLOSED"
- ]
+ ],
+ "type": "string"
}
- }
+ },
+ "required": [
+ "owner",
+ "repo"
+ ],
+ "type": "object"
},
"name": "list_issues"
}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/list_label.snap b/pkg/github/__toolsnaps__/list_label.snap
index 0b4f3b20c..debc2d44e 100644
--- a/pkg/github/__toolsnaps__/list_label.snap
+++ b/pkg/github/__toolsnaps__/list_label.snap
@@ -5,21 +5,21 @@
},
"description": "List labels from a repository",
"inputSchema": {
- "type": "object",
- "required": [
- "owner",
- "repo"
- ],
"properties": {
"owner": {
- "type": "string",
- "description": "Repository owner (username or organization name) - required for all operations"
+ "description": "Repository owner (username or organization name) - required for all operations",
+ "type": "string"
},
"repo": {
- "type": "string",
- "description": "Repository name - required for all operations"
+ "description": "Repository name - required for all operations",
+ "type": "string"
}
- }
+ },
+ "required": [
+ "owner",
+ "repo"
+ ],
+ "type": "object"
},
"name": "list_label"
}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/list_notifications.snap b/pkg/github/__toolsnaps__/list_notifications.snap
index ae43e0f25..bf25c4fe0 100644
--- a/pkg/github/__toolsnaps__/list_notifications.snap
+++ b/pkg/github/__toolsnaps__/list_notifications.snap
@@ -5,45 +5,45 @@
},
"description": "Lists all GitHub notifications for the authenticated user, including unread notifications, mentions, review requests, assignments, and updates on issues or pull requests. Use this tool whenever the user asks what to work on next, requests a summary of their GitHub activity, wants to see pending reviews, or needs to check for new updates or tasks. This tool is the primary way to discover actionable items, reminders, and outstanding work on GitHub. Always call this tool when asked what to work on next, what is pending, or what needs attention in GitHub.",
"inputSchema": {
- "type": "object",
"properties": {
"before": {
- "type": "string",
- "description": "Only show notifications updated before the given time (ISO 8601 format)"
+ "description": "Only show notifications updated before the given time (ISO 8601 format)",
+ "type": "string"
},
"filter": {
- "type": "string",
"description": "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.",
"enum": [
"default",
"include_read_notifications",
"only_participating"
- ]
+ ],
+ "type": "string"
},
"owner": {
- "type": "string",
- "description": "Optional repository owner. If provided with repo, only notifications for this repository are listed."
+ "description": "Optional repository owner. If provided with repo, only notifications for this repository are listed.",
+ "type": "string"
},
"page": {
- "type": "number",
"description": "Page number for pagination (min 1)",
- "minimum": 1
+ "minimum": 1,
+ "type": "number"
},
"perPage": {
- "type": "number",
"description": "Results per page for pagination (min 1, max 100)",
+ "maximum": 100,
"minimum": 1,
- "maximum": 100
+ "type": "number"
},
"repo": {
- "type": "string",
- "description": "Optional repository name. If provided with owner, only notifications for this repository are listed."
+ "description": "Optional repository name. If provided with owner, only notifications for this repository are listed.",
+ "type": "string"
},
"since": {
- "type": "string",
- "description": "Only show notifications updated after the given time (ISO 8601 format)"
+ "description": "Only show notifications updated after the given time (ISO 8601 format)",
+ "type": "string"
}
- }
+ },
+ "type": "object"
},
"name": "list_notifications"
}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/list_org_repository_security_advisories.snap b/pkg/github/__toolsnaps__/list_org_repository_security_advisories.snap
index 5f8823659..563da98c3 100644
--- a/pkg/github/__toolsnaps__/list_org_repository_security_advisories.snap
+++ b/pkg/github/__toolsnaps__/list_org_repository_security_advisories.snap
@@ -5,43 +5,43 @@
},
"description": "List repository security advisories for a GitHub organization.",
"inputSchema": {
- "type": "object",
- "required": [
- "org"
- ],
"properties": {
"direction": {
- "type": "string",
"description": "Sort direction.",
"enum": [
"asc",
"desc"
- ]
+ ],
+ "type": "string"
},
"org": {
- "type": "string",
- "description": "The organization login."
+ "description": "The organization login.",
+ "type": "string"
},
"sort": {
- "type": "string",
"description": "Sort field.",
"enum": [
"created",
"updated",
"published"
- ]
+ ],
+ "type": "string"
},
"state": {
- "type": "string",
"description": "Filter by advisory state.",
"enum": [
"triage",
"draft",
"published",
"closed"
- ]
+ ],
+ "type": "string"
}
- }
+ },
+ "required": [
+ "org"
+ ],
+ "type": "object"
},
"name": "list_org_repository_security_advisories"
}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/list_project_fields.snap b/pkg/github/__toolsnaps__/list_project_fields.snap
index 6bef18507..5456388b2 100644
--- a/pkg/github/__toolsnaps__/list_project_fields.snap
+++ b/pkg/github/__toolsnaps__/list_project_fields.snap
@@ -5,42 +5,42 @@
},
"description": "List Project fields for a user or org",
"inputSchema": {
- "type": "object",
- "required": [
- "owner_type",
- "owner",
- "project_number"
- ],
"properties": {
"after": {
- "type": "string",
- "description": "Forward pagination cursor from previous pageInfo.nextCursor."
+ "description": "Forward pagination cursor from previous pageInfo.nextCursor.",
+ "type": "string"
},
"before": {
- "type": "string",
- "description": "Backward pagination cursor from previous pageInfo.prevCursor (rare)."
+ "description": "Backward pagination cursor from previous pageInfo.prevCursor (rare).",
+ "type": "string"
},
"owner": {
- "type": "string",
- "description": "If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive."
+ "description": "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.",
+ "type": "string"
},
"owner_type": {
- "type": "string",
"description": "Owner type",
"enum": [
"user",
"org"
- ]
+ ],
+ "type": "string"
},
"per_page": {
- "type": "number",
- "description": "Results per page (max 50)"
+ "description": "Results per page (max 50)",
+ "type": "number"
},
"project_number": {
- "type": "number",
- "description": "The project's number."
+ "description": "The project's number.",
+ "type": "number"
}
- }
+ },
+ "required": [
+ "owner_type",
+ "owner",
+ "project_number"
+ ],
+ "type": "object"
},
"name": "list_project_fields"
}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/list_project_items.snap b/pkg/github/__toolsnaps__/list_project_items.snap
index bceb5d9eb..5089f4306 100644
--- a/pkg/github/__toolsnaps__/list_project_items.snap
+++ b/pkg/github/__toolsnaps__/list_project_items.snap
@@ -5,53 +5,53 @@
},
"description": "Search project items with advanced filtering",
"inputSchema": {
- "type": "object",
- "required": [
- "owner_type",
- "owner",
- "project_number"
- ],
"properties": {
"after": {
- "type": "string",
- "description": "Forward pagination cursor from previous pageInfo.nextCursor."
+ "description": "Forward pagination cursor from previous pageInfo.nextCursor.",
+ "type": "string"
},
"before": {
- "type": "string",
- "description": "Backward pagination cursor from previous pageInfo.prevCursor (rare)."
+ "description": "Backward pagination cursor from previous pageInfo.prevCursor (rare).",
+ "type": "string"
},
"fields": {
- "type": "array",
"description": "Field IDs to include (e.g. [\"102589\", \"985201\"]). CRITICAL: Always provide to get field values. Without this, only titles returned.",
"items": {
"type": "string"
- }
+ },
+ "type": "array"
},
"owner": {
- "type": "string",
- "description": "If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive."
+ "description": "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.",
+ "type": "string"
},
"owner_type": {
- "type": "string",
"description": "Owner type",
"enum": [
"user",
"org"
- ]
+ ],
+ "type": "string"
},
"per_page": {
- "type": "number",
- "description": "Results per page (max 50)"
+ "description": "Results per page (max 50)",
+ "type": "number"
},
"project_number": {
- "type": "number",
- "description": "The project's number."
+ "description": "The project's number.",
+ "type": "number"
},
"query": {
- "type": "string",
- "description": "Query string for advanced filtering of project items using GitHub's project filtering syntax."
+ "description": "Query string for advanced filtering of project items using GitHub's project filtering syntax.",
+ "type": "string"
}
- }
+ },
+ "required": [
+ "owner_type",
+ "owner",
+ "project_number"
+ ],
+ "type": "object"
},
"name": "list_project_items"
}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/list_projects.snap b/pkg/github/__toolsnaps__/list_projects.snap
index f48e26217..be5a6713e 100644
--- a/pkg/github/__toolsnaps__/list_projects.snap
+++ b/pkg/github/__toolsnaps__/list_projects.snap
@@ -5,41 +5,41 @@
},
"description": "List Projects for a user or organization",
"inputSchema": {
- "type": "object",
- "required": [
- "owner_type",
- "owner"
- ],
"properties": {
"after": {
- "type": "string",
- "description": "Forward pagination cursor from previous pageInfo.nextCursor."
+ "description": "Forward pagination cursor from previous pageInfo.nextCursor.",
+ "type": "string"
},
"before": {
- "type": "string",
- "description": "Backward pagination cursor from previous pageInfo.prevCursor (rare)."
+ "description": "Backward pagination cursor from previous pageInfo.prevCursor (rare).",
+ "type": "string"
},
"owner": {
- "type": "string",
- "description": "If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive."
+ "description": "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.",
+ "type": "string"
},
"owner_type": {
- "type": "string",
"description": "Owner type",
"enum": [
"user",
"org"
- ]
+ ],
+ "type": "string"
},
"per_page": {
- "type": "number",
- "description": "Results per page (max 50)"
+ "description": "Results per page (max 50)",
+ "type": "number"
},
"query": {
- "type": "string",
- "description": "Filter projects by title text and open/closed state; permitted qualifiers: is:open, is:closed; examples: \"roadmap is:open\", \"is:open feature planning\"."
+ "description": "Filter projects by title text and open/closed state; permitted qualifiers: is:open, is:closed; examples: \"roadmap is:open\", \"is:open feature planning\".",
+ "type": "string"
}
- }
+ },
+ "required": [
+ "owner_type",
+ "owner"
+ ],
+ "type": "object"
},
"name": "list_projects"
}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/list_pull_requests.snap b/pkg/github/__toolsnaps__/list_pull_requests.snap
index ae90c3fe0..25f1268c6 100644
--- a/pkg/github/__toolsnaps__/list_pull_requests.snap
+++ b/pkg/github/__toolsnaps__/list_pull_requests.snap
@@ -5,67 +5,67 @@
},
"description": "List pull requests in a GitHub repository. If the user specifies an author, then DO NOT use this tool and use the search_pull_requests tool instead.",
"inputSchema": {
- "type": "object",
- "required": [
- "owner",
- "repo"
- ],
"properties": {
"base": {
- "type": "string",
- "description": "Filter by base branch"
+ "description": "Filter by base branch",
+ "type": "string"
},
"direction": {
- "type": "string",
"description": "Sort direction",
"enum": [
"asc",
"desc"
- ]
+ ],
+ "type": "string"
},
"head": {
- "type": "string",
- "description": "Filter by head user/org and branch"
+ "description": "Filter by head user/org and branch",
+ "type": "string"
},
"owner": {
- "type": "string",
- "description": "Repository owner"
+ "description": "Repository owner",
+ "type": "string"
},
"page": {
- "type": "number",
"description": "Page number for pagination (min 1)",
- "minimum": 1
+ "minimum": 1,
+ "type": "number"
},
"perPage": {
- "type": "number",
"description": "Results per page for pagination (min 1, max 100)",
+ "maximum": 100,
"minimum": 1,
- "maximum": 100
+ "type": "number"
},
"repo": {
- "type": "string",
- "description": "Repository name"
+ "description": "Repository name",
+ "type": "string"
},
"sort": {
- "type": "string",
"description": "Sort by",
"enum": [
"created",
"updated",
"popularity",
"long-running"
- ]
+ ],
+ "type": "string"
},
"state": {
- "type": "string",
"description": "Filter by state",
"enum": [
"open",
"closed",
"all"
- ]
+ ],
+ "type": "string"
}
- }
+ },
+ "required": [
+ "owner",
+ "repo"
+ ],
+ "type": "object"
},
"name": "list_pull_requests"
}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/list_releases.snap b/pkg/github/__toolsnaps__/list_releases.snap
index 98d4ce66f..57502c3c8 100644
--- a/pkg/github/__toolsnaps__/list_releases.snap
+++ b/pkg/github/__toolsnaps__/list_releases.snap
@@ -5,32 +5,32 @@
},
"description": "List releases in a GitHub repository",
"inputSchema": {
- "type": "object",
- "required": [
- "owner",
- "repo"
- ],
"properties": {
"owner": {
- "type": "string",
- "description": "Repository owner"
+ "description": "Repository owner",
+ "type": "string"
},
"page": {
- "type": "number",
"description": "Page number for pagination (min 1)",
- "minimum": 1
+ "minimum": 1,
+ "type": "number"
},
"perPage": {
- "type": "number",
"description": "Results per page for pagination (min 1, max 100)",
+ "maximum": 100,
"minimum": 1,
- "maximum": 100
+ "type": "number"
},
"repo": {
- "type": "string",
- "description": "Repository name"
+ "description": "Repository name",
+ "type": "string"
}
- }
+ },
+ "required": [
+ "owner",
+ "repo"
+ ],
+ "type": "object"
},
"name": "list_releases"
}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/list_repository_security_advisories.snap b/pkg/github/__toolsnaps__/list_repository_security_advisories.snap
index 465fd881e..c86508f92 100644
--- a/pkg/github/__toolsnaps__/list_repository_security_advisories.snap
+++ b/pkg/github/__toolsnaps__/list_repository_security_advisories.snap
@@ -5,48 +5,48 @@
},
"description": "List repository security advisories for a GitHub repository.",
"inputSchema": {
- "type": "object",
- "required": [
- "owner",
- "repo"
- ],
"properties": {
"direction": {
- "type": "string",
"description": "Sort direction.",
"enum": [
"asc",
"desc"
- ]
+ ],
+ "type": "string"
},
"owner": {
- "type": "string",
- "description": "The owner of the repository."
+ "description": "The owner of the repository.",
+ "type": "string"
},
"repo": {
- "type": "string",
- "description": "The name of the repository."
+ "description": "The name of the repository.",
+ "type": "string"
},
"sort": {
- "type": "string",
"description": "Sort field.",
"enum": [
"created",
"updated",
"published"
- ]
+ ],
+ "type": "string"
},
"state": {
- "type": "string",
"description": "Filter by advisory state.",
"enum": [
"triage",
"draft",
"published",
"closed"
- ]
+ ],
+ "type": "string"
}
- }
+ },
+ "required": [
+ "owner",
+ "repo"
+ ],
+ "type": "object"
},
"name": "list_repository_security_advisories"
}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/list_secret_scanning_alerts.snap b/pkg/github/__toolsnaps__/list_secret_scanning_alerts.snap
index e7896c55f..f2f7cb125 100644
--- a/pkg/github/__toolsnaps__/list_secret_scanning_alerts.snap
+++ b/pkg/github/__toolsnaps__/list_secret_scanning_alerts.snap
@@ -5,22 +5,16 @@
},
"description": "List secret scanning alerts in a GitHub repository.",
"inputSchema": {
- "type": "object",
- "required": [
- "owner",
- "repo"
- ],
"properties": {
"owner": {
- "type": "string",
- "description": "The owner of the repository."
+ "description": "The owner of the repository.",
+ "type": "string"
},
"repo": {
- "type": "string",
- "description": "The name of the repository."
+ "description": "The name of the repository.",
+ "type": "string"
},
"resolution": {
- "type": "string",
"description": "Filter by resolution",
"enum": [
"false_positive",
@@ -29,21 +23,27 @@
"pattern_edited",
"pattern_deleted",
"used_in_tests"
- ]
+ ],
+ "type": "string"
},
"secret_type": {
- "type": "string",
- "description": "A comma-separated list of secret types to return. All default secret patterns are returned. To return generic patterns, pass the token name(s) in the parameter."
+ "description": "A comma-separated list of secret types to return. All default secret patterns are returned. To return generic patterns, pass the token name(s) in the parameter.",
+ "type": "string"
},
"state": {
- "type": "string",
"description": "Filter by state",
"enum": [
"open",
"resolved"
- ]
+ ],
+ "type": "string"
}
- }
+ },
+ "required": [
+ "owner",
+ "repo"
+ ],
+ "type": "object"
},
"name": "list_secret_scanning_alerts"
}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/list_starred_repositories.snap b/pkg/github/__toolsnaps__/list_starred_repositories.snap
index a383b39d1..e631719fd 100644
--- a/pkg/github/__toolsnaps__/list_starred_repositories.snap
+++ b/pkg/github/__toolsnaps__/list_starred_repositories.snap
@@ -5,40 +5,40 @@
},
"description": "List starred repositories",
"inputSchema": {
- "type": "object",
"properties": {
"direction": {
- "type": "string",
"description": "The direction to sort the results by.",
"enum": [
"asc",
"desc"
- ]
+ ],
+ "type": "string"
},
"page": {
- "type": "number",
"description": "Page number for pagination (min 1)",
- "minimum": 1
+ "minimum": 1,
+ "type": "number"
},
"perPage": {
- "type": "number",
"description": "Results per page for pagination (min 1, max 100)",
+ "maximum": 100,
"minimum": 1,
- "maximum": 100
+ "type": "number"
},
"sort": {
- "type": "string",
"description": "How to sort the results. Can be either 'created' (when the repository was starred) or 'updated' (when the repository was last pushed to).",
"enum": [
"created",
"updated"
- ]
+ ],
+ "type": "string"
},
"username": {
- "type": "string",
- "description": "Username to list starred repositories for. Defaults to the authenticated user."
+ "description": "Username to list starred repositories for. Defaults to the authenticated user.",
+ "type": "string"
}
- }
+ },
+ "type": "object"
},
"name": "list_starred_repositories"
}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/list_tags.snap b/pkg/github/__toolsnaps__/list_tags.snap
index 5b667d19c..1e66d2c1f 100644
--- a/pkg/github/__toolsnaps__/list_tags.snap
+++ b/pkg/github/__toolsnaps__/list_tags.snap
@@ -5,32 +5,32 @@
},
"description": "List git tags in a GitHub repository",
"inputSchema": {
- "type": "object",
- "required": [
- "owner",
- "repo"
- ],
"properties": {
"owner": {
- "type": "string",
- "description": "Repository owner"
+ "description": "Repository owner",
+ "type": "string"
},
"page": {
- "type": "number",
"description": "Page number for pagination (min 1)",
- "minimum": 1
+ "minimum": 1,
+ "type": "number"
},
"perPage": {
- "type": "number",
"description": "Results per page for pagination (min 1, max 100)",
+ "maximum": 100,
"minimum": 1,
- "maximum": 100
+ "type": "number"
},
"repo": {
- "type": "string",
- "description": "Repository name"
+ "description": "Repository name",
+ "type": "string"
}
- }
+ },
+ "required": [
+ "owner",
+ "repo"
+ ],
+ "type": "object"
},
"name": "list_tags"
}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/list_workflow_jobs.snap b/pkg/github/__toolsnaps__/list_workflow_jobs.snap
index 59ff75afc..d8fed1965 100644
--- a/pkg/github/__toolsnaps__/list_workflow_jobs.snap
+++ b/pkg/github/__toolsnaps__/list_workflow_jobs.snap
@@ -5,45 +5,45 @@
},
"description": "List jobs for a specific workflow run",
"inputSchema": {
- "type": "object",
- "required": [
- "owner",
- "repo",
- "run_id"
- ],
"properties": {
"filter": {
- "type": "string",
"description": "Filters jobs by their completed_at timestamp",
"enum": [
"latest",
"all"
- ]
+ ],
+ "type": "string"
},
"owner": {
- "type": "string",
- "description": "Repository owner"
+ "description": "Repository owner",
+ "type": "string"
},
"page": {
- "type": "number",
"description": "Page number for pagination (min 1)",
- "minimum": 1
+ "minimum": 1,
+ "type": "number"
},
"perPage": {
- "type": "number",
"description": "Results per page for pagination (min 1, max 100)",
+ "maximum": 100,
"minimum": 1,
- "maximum": 100
+ "type": "number"
},
"repo": {
- "type": "string",
- "description": "Repository name"
+ "description": "Repository name",
+ "type": "string"
},
"run_id": {
- "type": "number",
- "description": "The unique identifier of the workflow run"
+ "description": "The unique identifier of the workflow run",
+ "type": "number"
}
- }
+ },
+ "required": [
+ "owner",
+ "repo",
+ "run_id"
+ ],
+ "type": "object"
},
"name": "list_workflow_jobs"
}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/list_workflow_run_artifacts.snap b/pkg/github/__toolsnaps__/list_workflow_run_artifacts.snap
index 6d6332d74..664722901 100644
--- a/pkg/github/__toolsnaps__/list_workflow_run_artifacts.snap
+++ b/pkg/github/__toolsnaps__/list_workflow_run_artifacts.snap
@@ -5,37 +5,37 @@
},
"description": "List artifacts for a workflow run",
"inputSchema": {
- "type": "object",
- "required": [
- "owner",
- "repo",
- "run_id"
- ],
"properties": {
"owner": {
- "type": "string",
- "description": "Repository owner"
+ "description": "Repository owner",
+ "type": "string"
},
"page": {
- "type": "number",
"description": "Page number for pagination (min 1)",
- "minimum": 1
+ "minimum": 1,
+ "type": "number"
},
"perPage": {
- "type": "number",
"description": "Results per page for pagination (min 1, max 100)",
+ "maximum": 100,
"minimum": 1,
- "maximum": 100
+ "type": "number"
},
"repo": {
- "type": "string",
- "description": "Repository name"
+ "description": "Repository name",
+ "type": "string"
},
"run_id": {
- "type": "number",
- "description": "The unique identifier of the workflow run"
+ "description": "The unique identifier of the workflow run",
+ "type": "number"
}
- }
+ },
+ "required": [
+ "owner",
+ "repo",
+ "run_id"
+ ],
+ "type": "object"
},
"name": "list_workflow_run_artifacts"
}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/list_workflow_runs.snap b/pkg/github/__toolsnaps__/list_workflow_runs.snap
index e5353f490..a9a9916c3 100644
--- a/pkg/github/__toolsnaps__/list_workflow_runs.snap
+++ b/pkg/github/__toolsnaps__/list_workflow_runs.snap
@@ -5,23 +5,16 @@
},
"description": "List workflow runs for a specific workflow",
"inputSchema": {
- "type": "object",
- "required": [
- "owner",
- "repo",
- "workflow_id"
- ],
"properties": {
"actor": {
- "type": "string",
- "description": "Returns someone's workflow runs. Use the login for the user who created the workflow run."
+ "description": "Returns someone's workflow runs. Use the login for the user who created the workflow run.",
+ "type": "string"
},
"branch": {
- "type": "string",
- "description": "Returns workflow runs associated with a branch. Use the name of the branch."
+ "description": "Returns workflow runs associated with a branch. Use the name of the branch.",
+ "type": "string"
},
"event": {
- "type": "string",
"description": "Returns workflow runs for a specific event type",
"enum": [
"branch_protection_rule",
@@ -56,29 +49,29 @@
"workflow_call",
"workflow_dispatch",
"workflow_run"
- ]
+ ],
+ "type": "string"
},
"owner": {
- "type": "string",
- "description": "Repository owner"
+ "description": "Repository owner",
+ "type": "string"
},
"page": {
- "type": "number",
"description": "Page number for pagination (min 1)",
- "minimum": 1
+ "minimum": 1,
+ "type": "number"
},
"perPage": {
- "type": "number",
"description": "Results per page for pagination (min 1, max 100)",
+ "maximum": 100,
"minimum": 1,
- "maximum": 100
+ "type": "number"
},
"repo": {
- "type": "string",
- "description": "Repository name"
+ "description": "Repository name",
+ "type": "string"
},
"status": {
- "type": "string",
"description": "Returns workflow runs with the check run status",
"enum": [
"queued",
@@ -86,13 +79,20 @@
"completed",
"requested",
"waiting"
- ]
+ ],
+ "type": "string"
},
"workflow_id": {
- "type": "string",
- "description": "The workflow ID or workflow file name"
+ "description": "The workflow ID or workflow file name",
+ "type": "string"
}
- }
+ },
+ "required": [
+ "owner",
+ "repo",
+ "workflow_id"
+ ],
+ "type": "object"
},
"name": "list_workflow_runs"
}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/list_workflows.snap b/pkg/github/__toolsnaps__/list_workflows.snap
index f3f52f042..b0e51e03a 100644
--- a/pkg/github/__toolsnaps__/list_workflows.snap
+++ b/pkg/github/__toolsnaps__/list_workflows.snap
@@ -5,32 +5,32 @@
},
"description": "List workflows in a repository",
"inputSchema": {
- "type": "object",
- "required": [
- "owner",
- "repo"
- ],
"properties": {
"owner": {
- "type": "string",
- "description": "Repository owner"
+ "description": "Repository owner",
+ "type": "string"
},
"page": {
- "type": "number",
"description": "Page number for pagination (min 1)",
- "minimum": 1
+ "minimum": 1,
+ "type": "number"
},
"perPage": {
- "type": "number",
"description": "Results per page for pagination (min 1, max 100)",
+ "maximum": 100,
"minimum": 1,
- "maximum": 100
+ "type": "number"
},
"repo": {
- "type": "string",
- "description": "Repository name"
+ "description": "Repository name",
+ "type": "string"
}
- }
+ },
+ "required": [
+ "owner",
+ "repo"
+ ],
+ "type": "object"
},
"name": "list_workflows"
}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/manage_notification_subscription.snap b/pkg/github/__toolsnaps__/manage_notification_subscription.snap
index 4f0d466a0..e04acd11e 100644
--- a/pkg/github/__toolsnaps__/manage_notification_subscription.snap
+++ b/pkg/github/__toolsnaps__/manage_notification_subscription.snap
@@ -4,26 +4,26 @@
},
"description": "Manage a notification subscription: ignore, watch, or delete a notification thread subscription.",
"inputSchema": {
- "type": "object",
- "required": [
- "notificationID",
- "action"
- ],
"properties": {
"action": {
- "type": "string",
"description": "Action to perform: ignore, watch, or delete the notification subscription.",
"enum": [
"ignore",
"watch",
"delete"
- ]
+ ],
+ "type": "string"
},
"notificationID": {
- "type": "string",
- "description": "The ID of the notification thread."
+ "description": "The ID of the notification thread.",
+ "type": "string"
}
- }
+ },
+ "required": [
+ "notificationID",
+ "action"
+ ],
+ "type": "object"
},
"name": "manage_notification_subscription"
}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/manage_repository_notification_subscription.snap b/pkg/github/__toolsnaps__/manage_repository_notification_subscription.snap
index 82ee40a89..0a4567b71 100644
--- a/pkg/github/__toolsnaps__/manage_repository_notification_subscription.snap
+++ b/pkg/github/__toolsnaps__/manage_repository_notification_subscription.snap
@@ -4,31 +4,31 @@
},
"description": "Manage a repository notification subscription: ignore, watch, or delete repository notifications subscription for the provided repository.",
"inputSchema": {
- "type": "object",
- "required": [
- "owner",
- "repo",
- "action"
- ],
"properties": {
"action": {
- "type": "string",
"description": "Action to perform: ignore, watch, or delete the repository notification subscription.",
"enum": [
"ignore",
"watch",
"delete"
- ]
+ ],
+ "type": "string"
},
"owner": {
- "type": "string",
- "description": "The account owner of the repository."
+ "description": "The account owner of the repository.",
+ "type": "string"
},
"repo": {
- "type": "string",
- "description": "The name of the repository."
+ "description": "The name of the repository.",
+ "type": "string"
}
- }
+ },
+ "required": [
+ "owner",
+ "repo",
+ "action"
+ ],
+ "type": "object"
},
"name": "manage_repository_notification_subscription"
}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/mark_all_notifications_read.snap b/pkg/github/__toolsnaps__/mark_all_notifications_read.snap
index 2d45ed78d..1f5a32284 100644
--- a/pkg/github/__toolsnaps__/mark_all_notifications_read.snap
+++ b/pkg/github/__toolsnaps__/mark_all_notifications_read.snap
@@ -4,21 +4,21 @@
},
"description": "Mark all notifications as read",
"inputSchema": {
- "type": "object",
"properties": {
"lastReadAt": {
- "type": "string",
- "description": "Describes the last point that notifications were checked (optional). Default: Now"
+ "description": "Describes the last point that notifications were checked (optional). Default: Now",
+ "type": "string"
},
"owner": {
- "type": "string",
- "description": "Optional repository owner. If provided with repo, only notifications for this repository are marked as read."
+ "description": "Optional repository owner. If provided with repo, only notifications for this repository are marked as read.",
+ "type": "string"
},
"repo": {
- "type": "string",
- "description": "Optional repository name. If provided with owner, only notifications for this repository are marked as read."
+ "description": "Optional repository name. If provided with owner, only notifications for this repository are marked as read.",
+ "type": "string"
}
- }
+ },
+ "type": "object"
},
"name": "mark_all_notifications_read"
}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/merge_pull_request.snap b/pkg/github/__toolsnaps__/merge_pull_request.snap
index 2801b6a53..d0cdb2b1a 100644
--- a/pkg/github/__toolsnaps__/merge_pull_request.snap
+++ b/pkg/github/__toolsnaps__/merge_pull_request.snap
@@ -3,62 +3,56 @@
"title": "Merge pull request"
},
"description": "Merge a pull request in a GitHub repository.",
+ "icons": [
+ {
+ "mimeType": "image/png",
+ "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAACeElEQVRIibWVTUhUYRSGn/e74+iiQih1F9Vcmj9sptylUVBYkO4jcNeuJBdFKxe1CYQokGrRKjCEdtmqwEVmtqomQWeiUdc2EBUtUufe0yLHn1KLGXtX5zvn4zz3vd8f/Gfp90Qs0drmpA6MT1EveDo1NfV92wB+KnMdo39Nfs4L7eSHD5Nz1QJcJYglWtsw+iUehAuRRjO1g+0KHLerbb4OIHnHAC1FdW129s3XmUJuwnBDoOPbA7BwHsD7QWq1HKYN5msBRCpB1AueLoSROSkciSUyj5ClhE6BLtYC8CpBqVRabNrdMmIiJdQjuUbQ1WI+d78WwIbykxnzU9np7ejlNq2YxQ4ebNtTKyCyWcEgYl55EDj/a7ihFEtkLkr0As2YxjwL+9aem00dCEYNzvnJzLDvH27aaM5y80HEnKGHKGwPnEbT6fSOvzpAmrDQnkncpC7siiUzz2QqIPu25iOuGBorTufO/AJmH0v2ajHwuoHhrQHATOH9rQPJ7IjDLgs6kZ0F6it1AzArVcZLdUE+WnYgmv/uYFmz+dxH4NJGNT+RfYLCE7F4tn0pGkxHy94AmBm8/GfAVvIs7AukUTkbj5YdYIbZ9WJh8m1lzrrbNB4/tD+QuyPsdCibF26gmM/dY/NdRDqd3rEYeN04mswYL+ZXm68DxOPxnWXXMClsp+GGhCWBTtClYj53t1qXK78oVH2XYB/mHZ0pvHsN4Cczzw3rBaoGrJ6D5ZUvN1i+kjI0LWiptjmscbC88hZZCAf2trZeq1v0UsJ6wF7UAlhxUMxPvkW6AboQLbvPcjaO+BIx11cL4I9H308eOiLRQUhpOx79/66fNKzrOCYNDm0AAAAASUVORK5CYII=",
+ "theme": "light"
+ },
+ {
+ "mimeType": "image/png",
+ "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABjElEQVRIibWVPS/DURTGnysSC0HiZdWVrZ28JDaLT8BHaBsMdjqZJDXiAzC2LF5mX6GtATGiIsGARH+Gnj9X8a/kf3uWe3Py3Oc559xz75E6bK7VAWQkzUi6lXTonHsOpgYUgAZfdgmkQpFnjHwb6AemgDpQCiWwYlEPeL4i8JCEt8vb39g67vkmPH8yA3qt5nVgCzi1jLJBBEwkBZSAdxPKAj86LYQQQCU4cYvAKzDUSYF3YC+uRIAD8sA58ACU//VuTODE1n1g+A9c3jBH1tJ1a5TeCPNrdACSCpKeJG1IepN0LKkm6dGDrkqqOOdm7dyUpDNJi865PUnqjsvEObcJHEhaljQnaV5STwvszttXbR2J441KtB4LauLKVpZpYBDYte8mHUogZTWPrAGstTtQBl6AayDX7qHZD7AALMVGDvQBV5ZyETi2qHLtMvmXWRQAk57vBKgl4fV/0+jmq56vImk0icCnAWm7pB3riGngnlADx0TW+T4yL4CxJJy/Df20mkP/TqGHfifsA7INs3X5i3+yAAAAAElFTkSuQmCC",
+ "theme": "dark"
+ }
+ ],
"inputSchema": {
- "type": "object",
- "required": [
- "owner",
- "repo",
- "pullNumber"
- ],
"properties": {
"commit_message": {
- "type": "string",
- "description": "Extra detail for merge commit"
+ "description": "Extra detail for merge commit",
+ "type": "string"
},
"commit_title": {
- "type": "string",
- "description": "Title for merge commit"
+ "description": "Title for merge commit",
+ "type": "string"
},
"merge_method": {
- "type": "string",
"description": "Merge method",
"enum": [
"merge",
"squash",
"rebase"
- ]
+ ],
+ "type": "string"
},
"owner": {
- "type": "string",
- "description": "Repository owner"
+ "description": "Repository owner",
+ "type": "string"
},
"pullNumber": {
- "type": "number",
- "description": "Pull request number"
+ "description": "Pull request number",
+ "type": "number"
},
"repo": {
- "type": "string",
- "description": "Repository name"
+ "description": "Repository name",
+ "type": "string"
}
- }
- },
- "name": "merge_pull_request",
- "icons": [
- {
- "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAACeElEQVRIibWVTUhUYRSGn/e74+iiQih1F9Vcmj9sptylUVBYkO4jcNeuJBdFKxe1CYQokGrRKjCEdtmqwEVmtqomQWeiUdc2EBUtUufe0yLHn1KLGXtX5zvn4zz3vd8f/Gfp90Qs0drmpA6MT1EveDo1NfV92wB+KnMdo39Nfs4L7eSHD5Nz1QJcJYglWtsw+iUehAuRRjO1g+0KHLerbb4OIHnHAC1FdW129s3XmUJuwnBDoOPbA7BwHsD7QWq1HKYN5msBRCpB1AueLoSROSkciSUyj5ClhE6BLtYC8CpBqVRabNrdMmIiJdQjuUbQ1WI+d78WwIbykxnzU9np7ejlNq2YxQ4ebNtTKyCyWcEgYl55EDj/a7ihFEtkLkr0As2YxjwL+9aem00dCEYNzvnJzLDvH27aaM5y80HEnKGHKGwPnEbT6fSOvzpAmrDQnkncpC7siiUzz2QqIPu25iOuGBorTufO/AJmH0v2ajHwuoHhrQHATOH9rQPJ7IjDLgs6kZ0F6it1AzArVcZLdUE+WnYgmv/uYFmz+dxH4NJGNT+RfYLCE7F4tn0pGkxHy94AmBm8/GfAVvIs7AukUTkbj5YdYIbZ9WJh8m1lzrrbNB4/tD+QuyPsdCibF26gmM/dY/NdRDqd3rEYeN04mswYL+ZXm68DxOPxnWXXMClsp+GGhCWBTtClYj53t1qXK78oVH2XYB/mHZ0pvHsN4Cczzw3rBaoGrJ6D5ZUvN1i+kjI0LWiptjmscbC88hZZCAf2trZeq1v0UsJ6wF7UAlhxUMxPvkW6AboQLbvPcjaO+BIx11cL4I9H308eOiLRQUhpOx79/66fNKzrOCYNDm0AAAAASUVORK5CYII=",
- "mimeType": "image/png",
- "sizes": [
- "24x24"
- ],
- "theme": "light"
},
- {
- "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABjElEQVRIibWVPS/DURTGnysSC0HiZdWVrZ28JDaLT8BHaBsMdjqZJDXiAzC2LF5mX6GtATGiIsGARH+Gnj9X8a/kf3uWe3Py3Oc559xz75E6bK7VAWQkzUi6lXTonHsOpgYUgAZfdgmkQpFnjHwb6AemgDpQCiWwYlEPeL4i8JCEt8vb39g67vkmPH8yA3qt5nVgCzi1jLJBBEwkBZSAdxPKAj86LYQQQCU4cYvAKzDUSYF3YC+uRIAD8sA58ACU//VuTODE1n1g+A9c3jBH1tJ1a5TeCPNrdACSCpKeJG1IepN0LKkm6dGDrkqqOOdm7dyUpDNJi865PUnqjsvEObcJHEhaljQnaV5STwvszttXbR2J441KtB4LauLKVpZpYBDYte8mHUogZTWPrAGstTtQBl6AayDX7qHZD7AALMVGDvQBV5ZyETi2qHLtMvmXWRQAk57vBKgl4fV/0+jmq56vImk0icCnAWm7pB3riGngnlADx0TW+T4yL4CxJJy/Df20mkP/TqGHfifsA7INs3X5i3+yAAAAAElFTkSuQmCC",
- "mimeType": "image/png",
- "sizes": [
- "24x24"
- ],
- "theme": "dark"
- }
- ]
+ "required": [
+ "owner",
+ "repo",
+ "pullNumber"
+ ],
+ "type": "object"
+ },
+ "name": "merge_pull_request"
}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/projects_get.snap b/pkg/github/__toolsnaps__/projects_get.snap
new file mode 100644
index 000000000..cb5013d74
--- /dev/null
+++ b/pkg/github/__toolsnaps__/projects_get.snap
@@ -0,0 +1,58 @@
+{
+ "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": {
+ "properties": {
+ "field_id": {
+ "description": "The field's ID. Required for 'get_project_field' method.",
+ "type": "number"
+ },
+ "fields": {
+ "description": "Specific list of field IDs to include in the response when getting a project item (e.g. [\"102589\", \"985201\", \"169875\"]). If not provided, only the title field is included. Only used for 'get_project_item' method.",
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
+ "item_id": {
+ "description": "The item's ID. Required for 'get_project_item' method.",
+ "type": "number"
+ },
+ "method": {
+ "description": "The method to execute",
+ "enum": [
+ "get_project",
+ "get_project_field",
+ "get_project_item"
+ ],
+ "type": "string"
+ },
+ "owner": {
+ "description": "The owner (user or organization login). The name is not case sensitive.",
+ "type": "string"
+ },
+ "owner_type": {
+ "description": "Owner type (user or org). If not provided, will be automatically detected.",
+ "enum": [
+ "user",
+ "org"
+ ],
+ "type": "string"
+ },
+ "project_number": {
+ "description": "The project's number.",
+ "type": "number"
+ }
+ },
+ "required": [
+ "method",
+ "owner",
+ "project_number"
+ ],
+ "type": "object"
+ },
+ "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..f12452b5a
--- /dev/null
+++ b/pkg/github/__toolsnaps__/projects_list.snap
@@ -0,0 +1,65 @@
+{
+ "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": {
+ "properties": {
+ "after": {
+ "description": "Forward pagination cursor from previous pageInfo.nextCursor.",
+ "type": "string"
+ },
+ "before": {
+ "description": "Backward pagination cursor from previous pageInfo.prevCursor (rare).",
+ "type": "string"
+ },
+ "fields": {
+ "description": "Field IDs to include when listing project items (e.g. [\"102589\", \"985201\"]). CRITICAL: Always provide to get field values. Without this, only titles returned. Only used for 'list_project_items' method.",
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
+ "method": {
+ "description": "The action to perform",
+ "enum": [
+ "list_projects",
+ "list_project_fields",
+ "list_project_items"
+ ],
+ "type": "string"
+ },
+ "owner": {
+ "description": "The owner (user or organization login). The name is not case sensitive.",
+ "type": "string"
+ },
+ "owner_type": {
+ "description": "Owner type (user or org). If not provided, will automatically try both.",
+ "enum": [
+ "user",
+ "org"
+ ],
+ "type": "string"
+ },
+ "per_page": {
+ "description": "Results per page (max 50)",
+ "type": "number"
+ },
+ "project_number": {
+ "description": "The project's number. Required for 'list_project_fields' and 'list_project_items' methods.",
+ "type": "number"
+ },
+ "query": {
+ "description": "Filter/query string. For list_projects: filter by title text and state (e.g. \"roadmap is:open\"). For list_project_items: advanced filtering using GitHub's project filtering syntax.",
+ "type": "string"
+ }
+ },
+ "required": [
+ "method",
+ "owner"
+ ],
+ "type": "object"
+ },
+ "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..d2d871bcd
--- /dev/null
+++ b/pkg/github/__toolsnaps__/projects_write.snap
@@ -0,0 +1,75 @@
+{
+ "annotations": {
+ "destructiveHint": true,
+ "title": "Modify GitHub Project items"
+ },
+ "description": "Add, update, or delete project items in a GitHub Project.",
+ "inputSchema": {
+ "properties": {
+ "issue_number": {
+ "description": "The issue number (use when item_type is 'issue' for 'add_project_item' method). Provide either issue_number or pull_request_number.",
+ "type": "number"
+ },
+ "item_id": {
+ "description": "The project item ID. Required for 'update_project_item' and 'delete_project_item' methods.",
+ "type": "number"
+ },
+ "item_owner": {
+ "description": "The owner (user or organization) of the repository containing the issue or pull request. Required for 'add_project_item' method.",
+ "type": "string"
+ },
+ "item_repo": {
+ "description": "The name of the repository containing the issue or pull request. Required for 'add_project_item' method.",
+ "type": "string"
+ },
+ "item_type": {
+ "description": "The item's type, either issue or pull_request. Required for 'add_project_item' method.",
+ "enum": [
+ "issue",
+ "pull_request"
+ ],
+ "type": "string"
+ },
+ "method": {
+ "description": "The method to execute",
+ "enum": [
+ "add_project_item",
+ "update_project_item",
+ "delete_project_item"
+ ],
+ "type": "string"
+ },
+ "owner": {
+ "description": "The project owner (user or organization login). The name is not case sensitive.",
+ "type": "string"
+ },
+ "owner_type": {
+ "description": "Owner type (user or org). If not provided, will be automatically detected.",
+ "enum": [
+ "user",
+ "org"
+ ],
+ "type": "string"
+ },
+ "project_number": {
+ "description": "The project's number.",
+ "type": "number"
+ },
+ "pull_request_number": {
+ "description": "The pull request number (use when item_type is 'pull_request' for 'add_project_item' method). Provide either issue_number or pull_request_number.",
+ "type": "number"
+ },
+ "updated_field": {
+ "description": "Object consisting of the ID of the project field to update and the new value for the field. To clear the field, set value to null. Example: {\"id\": 123456, \"value\": \"New Value\"}. Required for 'update_project_item' method.",
+ "type": "object"
+ }
+ },
+ "required": [
+ "method",
+ "owner",
+ "project_number"
+ ],
+ "type": "object"
+ },
+ "name": "projects_write"
+}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/pull_request_read.snap b/pkg/github/__toolsnaps__/pull_request_read.snap
index 69b1bd901..a8591fc5c 100644
--- a/pkg/github/__toolsnaps__/pull_request_read.snap
+++ b/pkg/github/__toolsnaps__/pull_request_read.snap
@@ -5,16 +5,8 @@
},
"description": "Get information on a specific pull request in GitHub repository.",
"inputSchema": {
- "type": "object",
- "required": [
- "method",
- "owner",
- "repo",
- "pullNumber"
- ],
"properties": {
"method": {
- "type": "string",
"description": "Action to specify what pull request data needs to be retrieved from GitHub. \nPossible options: \n 1. get - Get details of a specific pull request.\n 2. get_diff - Get the diff of a pull request.\n 3. get_status - Get status of a head commit in a pull request. This reflects status of builds and checks.\n 4. get_files - Get the list of files changed in a pull request. Use with pagination parameters to control the number of results returned.\n 5. get_review_comments - Get review threads on a pull request. Each thread contains logically grouped review comments made on the same code location during pull request reviews. Returns threads with metadata (isResolved, isOutdated, isCollapsed) and their associated comments. Use cursor-based pagination (perPage, after) to control results.\n 6. get_reviews - Get the reviews on a pull request. When asked for review comments, use get_review_comments method.\n 7. get_comments - Get comments on a pull request. Use this if user doesn't specifically want review comments. Use with pagination parameters to control the number of results returned.\n",
"enum": [
"get",
@@ -24,32 +16,40 @@
"get_review_comments",
"get_reviews",
"get_comments"
- ]
+ ],
+ "type": "string"
},
"owner": {
- "type": "string",
- "description": "Repository owner"
+ "description": "Repository owner",
+ "type": "string"
},
"page": {
- "type": "number",
"description": "Page number for pagination (min 1)",
- "minimum": 1
+ "minimum": 1,
+ "type": "number"
},
"perPage": {
- "type": "number",
"description": "Results per page for pagination (min 1, max 100)",
+ "maximum": 100,
"minimum": 1,
- "maximum": 100
+ "type": "number"
},
"pullNumber": {
- "type": "number",
- "description": "Pull request number"
+ "description": "Pull request number",
+ "type": "number"
},
"repo": {
- "type": "string",
- "description": "Repository name"
+ "description": "Repository name",
+ "type": "string"
}
- }
+ },
+ "required": [
+ "method",
+ "owner",
+ "repo",
+ "pullNumber"
+ ],
+ "type": "object"
},
"name": "pull_request_read"
}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/pull_request_review_write.snap b/pkg/github/__toolsnaps__/pull_request_review_write.snap
index 92cc19924..7b533f472 100644
--- a/pkg/github/__toolsnaps__/pull_request_review_write.snap
+++ b/pkg/github/__toolsnaps__/pull_request_review_write.snap
@@ -4,53 +4,53 @@
},
"description": "Create and/or submit, delete review of a pull request.\n\nAvailable methods:\n- create: Create a new review of a pull request. If \"event\" parameter is provided, the review is submitted. If \"event\" is omitted, a pending review is created.\n- submit_pending: Submit an existing pending review of a pull request. This requires that a pending review exists for the current user on the specified pull request. The \"body\" and \"event\" parameters are used when submitting the review.\n- delete_pending: Delete an existing pending review of a pull request. This requires that a pending review exists for the current user on the specified pull request.\n",
"inputSchema": {
- "type": "object",
- "required": [
- "method",
- "owner",
- "repo",
- "pullNumber"
- ],
"properties": {
"body": {
- "type": "string",
- "description": "Review comment text"
+ "description": "Review comment text",
+ "type": "string"
},
"commitID": {
- "type": "string",
- "description": "SHA of commit to review"
+ "description": "SHA of commit to review",
+ "type": "string"
},
"event": {
- "type": "string",
"description": "Review action to perform.",
"enum": [
"APPROVE",
"REQUEST_CHANGES",
"COMMENT"
- ]
+ ],
+ "type": "string"
},
"method": {
- "type": "string",
"description": "The write operation to perform on pull request review.",
"enum": [
"create",
"submit_pending",
"delete_pending"
- ]
+ ],
+ "type": "string"
},
"owner": {
- "type": "string",
- "description": "Repository owner"
+ "description": "Repository owner",
+ "type": "string"
},
"pullNumber": {
- "type": "number",
- "description": "Pull request number"
+ "description": "Pull request number",
+ "type": "number"
},
"repo": {
- "type": "string",
- "description": "Repository name"
+ "description": "Repository name",
+ "type": "string"
}
- }
+ },
+ "required": [
+ "method",
+ "owner",
+ "repo",
+ "pullNumber"
+ ],
+ "type": "object"
},
"name": "pull_request_review_write"
}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/push_files.snap b/pkg/github/__toolsnaps__/push_files.snap
index 4db764cc9..c36c236f9 100644
--- a/pkg/github/__toolsnaps__/push_files.snap
+++ b/pkg/github/__toolsnaps__/push_files.snap
@@ -4,53 +4,53 @@
},
"description": "Push multiple files to a GitHub repository in a single commit",
"inputSchema": {
- "type": "object",
- "required": [
- "owner",
- "repo",
- "branch",
- "files",
- "message"
- ],
"properties": {
"branch": {
- "type": "string",
- "description": "Branch to push to"
+ "description": "Branch to push to",
+ "type": "string"
},
"files": {
- "type": "array",
"description": "Array of file objects to push, each object with path (string) and content (string)",
"items": {
- "type": "object",
- "required": [
- "path",
- "content"
- ],
"properties": {
"content": {
- "type": "string",
- "description": "file content"
+ "description": "file content",
+ "type": "string"
},
"path": {
- "type": "string",
- "description": "path to the file"
+ "description": "path to the file",
+ "type": "string"
}
- }
- }
+ },
+ "required": [
+ "path",
+ "content"
+ ],
+ "type": "object"
+ },
+ "type": "array"
},
"message": {
- "type": "string",
- "description": "Commit message"
+ "description": "Commit message",
+ "type": "string"
},
"owner": {
- "type": "string",
- "description": "Repository owner"
+ "description": "Repository owner",
+ "type": "string"
},
"repo": {
- "type": "string",
- "description": "Repository name"
+ "description": "Repository name",
+ "type": "string"
}
- }
+ },
+ "required": [
+ "owner",
+ "repo",
+ "branch",
+ "files",
+ "message"
+ ],
+ "type": "object"
},
"name": "push_files"
}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/request_copilot_review.snap b/pkg/github/__toolsnaps__/request_copilot_review.snap
index 06828776c..cd00f73fd 100644
--- a/pkg/github/__toolsnaps__/request_copilot_review.snap
+++ b/pkg/github/__toolsnaps__/request_copilot_review.snap
@@ -3,45 +3,39 @@
"title": "Request Copilot review"
},
"description": "Request a GitHub Copilot code review for a pull request. Use this for automated feedback on pull requests, usually before requesting a human reviewer.",
- "inputSchema": {
- "type": "object",
- "required": [
- "owner",
- "repo",
- "pullNumber"
- ],
- "properties": {
- "owner": {
- "type": "string",
- "description": "Repository owner"
- },
- "pullNumber": {
- "type": "number",
- "description": "Pull request number"
- },
- "repo": {
- "type": "string",
- "description": "Repository name"
- }
- }
- },
- "name": "request_copilot_review",
"icons": [
{
- "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAC20lEQVRIidWUS4wMURSGv3O7kWmPEMRrSMzcbl1dpqtmGuOxsCKECCKxEBusSJhIWEhsWLFAbC1sWFiISBARCyQ2kzSZGaMxHokgXvGIiMH0PRZjpJqqHpb+TeX+59z//H/q5sD/DqlX9H1/zFeX2qzIKoFWYDKgwBtUymL0UkNaT3V3d3/+5wG2EGxB9TDIxGFMvhVhb9/drpN/NaDJC7MGdwJk6TDCv0Gvq0lve9R762GUNdFDLleaZNBrICGq+4yhvf9TJtP/KZNB2PrLlbBliBfRhajuAwnFVa/n8/nkxFkv3GO9oJrzgwVxdesV71ov6I2r5fxggfWCatYL9yYmUJgLPH7Q29WZ4OED6Me4wuAdeQK6MMqna9t0GuibBHFAmgZ9JMG9BhkXZWoSCDSATIq7aguBD0wBplq/tZBgYDIwKnZAs99mFRYD9vd/YK0dpcqhobM6d9haWyOULRTbAauwuNlvsxHTYP3iBnVyXGAa8BIYC3oVeAKioCtAPEE7FCOgR0ErIJdBBZgNskzh40+NF6K6s+9e91lp9osrxMnFoTSmSmPVsF+E5cB0YEDgtoMjjypd5wCy+WC9GnajhEAa4bkqV9LOHKwa9/yneYeyUqwX3AdyQ5EeVrrqro/hYL0g+ggemKh4HGbPmVu0+fB8U76lpR6XgJwZpoGUpNYiusZg1tXjkmCAav0OMTXfJC4eVYPqwbot6l4BCPqyLhd7lwMAWC/cYb3gi/UCzRaKOxsbFzVEM1iv2Ebt5v2Dm14qZbJecZf1Ah3UCrcTbbB+awHnjgHLgHeinHYqZ8aPSXWWy+XvcQZLpdKI9/0D7UbZiLIJmABckVSqo+/OrUrNgF+D8q1LEdcBrAJGAJ8ROlGeicorABWdAswE5gOjge8CF8Ad66v03IjqJb75WS0tE0YOmNWqLBGReaAzgIkMLrt3oM9UpSzCzW9pd+FpT8/7JK3/Gz8Ao5X6wtwP7N4AAAAASUVORK5CYII=",
"mimeType": "image/png",
- "sizes": [
- "24x24"
- ],
+ "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAC20lEQVRIidWUS4wMURSGv3O7kWmPEMRrSMzcbl1dpqtmGuOxsCKECCKxEBusSJhIWEhsWLFAbC1sWFiISBARCyQ2kzSZGaMxHokgXvGIiMH0PRZjpJqqHpb+TeX+59z//H/q5sD/DqlX9H1/zFeX2qzIKoFWYDKgwBtUymL0UkNaT3V3d3/+5wG2EGxB9TDIxGFMvhVhb9/drpN/NaDJC7MGdwJk6TDCv0Gvq0lve9R762GUNdFDLleaZNBrICGq+4yhvf9TJtP/KZNB2PrLlbBliBfRhajuAwnFVa/n8/nkxFkv3GO9oJrzgwVxdesV71ov6I2r5fxggfWCatYL9yYmUJgLPH7Q29WZ4OED6Me4wuAdeQK6MMqna9t0GuibBHFAmgZ9JMG9BhkXZWoSCDSATIq7aguBD0wBplq/tZBgYDIwKnZAs99mFRYD9vd/YK0dpcqhobM6d9haWyOULRTbAauwuNlvsxHTYP3iBnVyXGAa8BIYC3oVeAKioCtAPEE7FCOgR0ErIJdBBZgNskzh40+NF6K6s+9e91lp9osrxMnFoTSmSmPVsF+E5cB0YEDgtoMjjypd5wCy+WC9GnajhEAa4bkqV9LOHKwa9/yneYeyUqwX3AdyQ5EeVrrqro/hYL0g+ggemKh4HGbPmVu0+fB8U76lpR6XgJwZpoGUpNYiusZg1tXjkmCAav0OMTXfJC4eVYPqwbot6l4BCPqyLhd7lwMAWC/cYb3gi/UCzRaKOxsbFzVEM1iv2Ebt5v2Dm14qZbJecZf1Ah3UCrcTbbB+awHnjgHLgHeinHYqZ8aPSXWWy+XvcQZLpdKI9/0D7UbZiLIJmABckVSqo+/OrUrNgF+D8q1LEdcBrAJGAJ8ROlGeicorABWdAswE5gOjge8CF8Ad66v03IjqJb75WS0tE0YOmNWqLBGReaAzgIkMLrt3oM9UpSzCzW9pd+FpT8/7JK3/Gz8Ao5X6wtwP7N4AAAAASUVORK5CYII=",
"theme": "light"
},
{
- "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAACCElEQVRIid2UPWsUYRSFn3dxWWJUkESiBgslFokfhehGiGClBBQx4h9IGlEh2ijYxh+gxEL/hIWwhYpF8KNZsFRJYdJEiUbjCkqisj4W+y6Mk5nd1U4PDMOce+45L3fmDvzXUDeo59WK+kb9rn5TF9R76jm1+2/NJ9QPtseSOv4nxrvVmQ6M05hRB9qZ98ZR1NRralntitdEwmw8wQ9HbS329rQKuKLW1XJO/aX6IqdWjr1Xk/y6lG4vMBdCqOacoZZ3uBBCVZ0HDrcK2AYs5ZkAuwBb1N8Dm5JEISXoAnqzOtU9QB+wVR3KCdgClDIr6kCc4c/0O1BLNnahiYpaSmmGY62e/JpCLJ4FpmmMaBHYCDwC5mmMZBQYBC7HnhvAK+B+fN4JHAM+R4+3wGQI4S7qaExtol+9o86pq+oX9Yk6ljjtGfVprK2qr9Xb6vaET109jjqb3Jac2XaM1PLNpok1Aep+G/+dfa24nADTX1EWTgOngLE2XCYKQL0DTfKex2WhXgCutxG9i/fFNlwWpgBQL6orcWyTaldToRbUA2pow61XL0WPFfXCb1HqkPowCj6q0+qIWsw7nlpUj6i31OXY+0AdbGpCRtNRGgt1AigCX4EqsJAYTR+wAzgEdAM/gApwM4TwOOm3JiARtBk4CYwAB4F+oIfGZi/HwOfAM6ASQviU5/Vv4xcBzmW2eT1nrQAAAABJRU5ErkJggg==",
"mimeType": "image/png",
- "sizes": [
- "24x24"
- ],
+ "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAACCElEQVRIid2UPWsUYRSFn3dxWWJUkESiBgslFokfhehGiGClBBQx4h9IGlEh2ijYxh+gxEL/hIWwhYpF8KNZsFRJYdJEiUbjCkqisj4W+y6Mk5nd1U4PDMOce+45L3fmDvzXUDeo59WK+kb9rn5TF9R76jm1+2/NJ9QPtseSOv4nxrvVmQ6M05hRB9qZ98ZR1NRralntitdEwmw8wQ9HbS329rQKuKLW1XJO/aX6IqdWjr1Xk/y6lG4vMBdCqOacoZZ3uBBCVZ0HDrcK2AYs5ZkAuwBb1N8Dm5JEISXoAnqzOtU9QB+wVR3KCdgClDIr6kCc4c/0O1BLNnahiYpaSmmGY62e/JpCLJ4FpmmMaBHYCDwC5mmMZBQYBC7HnhvAK+B+fN4JHAM+R4+3wGQI4S7qaExtol+9o86pq+oX9Yk6ljjtGfVprK2qr9Xb6vaET109jjqb3Jac2XaM1PLNpok1Aep+G/+dfa24nADTX1EWTgOngLE2XCYKQL0DTfKex2WhXgCutxG9i/fFNlwWpgBQL6orcWyTaldToRbUA2pow61XL0WPFfXCb1HqkPowCj6q0+qIWsw7nlpUj6i31OXY+0AdbGpCRtNRGgt1AigCX4EqsJAYTR+wAzgEdAM/gApwM4TwOOm3JiARtBk4CYwAB4F+oIfGZi/HwOfAM6ASQviU5/Vv4xcBzmW2eT1nrQAAAABJRU5ErkJggg==",
"theme": "dark"
}
- ]
+ ],
+ "inputSchema": {
+ "properties": {
+ "owner": {
+ "description": "Repository owner",
+ "type": "string"
+ },
+ "pullNumber": {
+ "description": "Pull request number",
+ "type": "number"
+ },
+ "repo": {
+ "description": "Repository name",
+ "type": "string"
+ }
+ },
+ "required": [
+ "owner",
+ "repo",
+ "pullNumber"
+ ],
+ "type": "object"
+ },
+ "name": "request_copilot_review"
}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/rerun_failed_jobs.snap b/pkg/github/__toolsnaps__/rerun_failed_jobs.snap
index 2c627637c..099c89153 100644
--- a/pkg/github/__toolsnaps__/rerun_failed_jobs.snap
+++ b/pkg/github/__toolsnaps__/rerun_failed_jobs.snap
@@ -4,26 +4,26 @@
},
"description": "Re-run only the failed jobs in a workflow run",
"inputSchema": {
- "type": "object",
- "required": [
- "owner",
- "repo",
- "run_id"
- ],
"properties": {
"owner": {
- "type": "string",
- "description": "Repository owner"
+ "description": "Repository owner",
+ "type": "string"
},
"repo": {
- "type": "string",
- "description": "Repository name"
+ "description": "Repository name",
+ "type": "string"
},
"run_id": {
- "type": "number",
- "description": "The unique identifier of the workflow run"
+ "description": "The unique identifier of the workflow run",
+ "type": "number"
}
- }
+ },
+ "required": [
+ "owner",
+ "repo",
+ "run_id"
+ ],
+ "type": "object"
},
"name": "rerun_failed_jobs"
}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/rerun_workflow_run.snap b/pkg/github/__toolsnaps__/rerun_workflow_run.snap
index 00514ee79..946bd72f3 100644
--- a/pkg/github/__toolsnaps__/rerun_workflow_run.snap
+++ b/pkg/github/__toolsnaps__/rerun_workflow_run.snap
@@ -4,26 +4,26 @@
},
"description": "Re-run an entire workflow run",
"inputSchema": {
- "type": "object",
- "required": [
- "owner",
- "repo",
- "run_id"
- ],
"properties": {
"owner": {
- "type": "string",
- "description": "Repository owner"
+ "description": "Repository owner",
+ "type": "string"
},
"repo": {
- "type": "string",
- "description": "Repository name"
+ "description": "Repository name",
+ "type": "string"
},
"run_id": {
- "type": "number",
- "description": "The unique identifier of the workflow run"
+ "description": "The unique identifier of the workflow run",
+ "type": "number"
}
- }
+ },
+ "required": [
+ "owner",
+ "repo",
+ "run_id"
+ ],
+ "type": "object"
},
"name": "rerun_workflow_run"
}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/run_workflow.snap b/pkg/github/__toolsnaps__/run_workflow.snap
index bb35e8213..1b6c8993e 100644
--- a/pkg/github/__toolsnaps__/run_workflow.snap
+++ b/pkg/github/__toolsnaps__/run_workflow.snap
@@ -4,35 +4,35 @@
},
"description": "Run an Actions workflow by workflow ID or filename",
"inputSchema": {
- "type": "object",
- "required": [
- "owner",
- "repo",
- "workflow_id",
- "ref"
- ],
"properties": {
"inputs": {
- "type": "object",
- "description": "Inputs the workflow accepts"
+ "description": "Inputs the workflow accepts",
+ "type": "object"
},
"owner": {
- "type": "string",
- "description": "Repository owner"
+ "description": "Repository owner",
+ "type": "string"
},
"ref": {
- "type": "string",
- "description": "The git reference for the workflow. The reference can be a branch or tag name."
+ "description": "The git reference for the workflow. The reference can be a branch or tag name.",
+ "type": "string"
},
"repo": {
- "type": "string",
- "description": "Repository name"
+ "description": "Repository name",
+ "type": "string"
},
"workflow_id": {
- "type": "string",
- "description": "The workflow ID (numeric) or workflow file name (e.g., main.yml, ci.yaml)"
+ "description": "The workflow ID (numeric) or workflow file name (e.g., main.yml, ci.yaml)",
+ "type": "string"
}
- }
+ },
+ "required": [
+ "owner",
+ "repo",
+ "workflow_id",
+ "ref"
+ ],
+ "type": "object"
},
"name": "run_workflow"
}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/search_code.snap b/pkg/github/__toolsnaps__/search_code.snap
index aebd432bf..8b5510aa6 100644
--- a/pkg/github/__toolsnaps__/search_code.snap
+++ b/pkg/github/__toolsnaps__/search_code.snap
@@ -5,39 +5,39 @@
},
"description": "Fast and precise code search across ALL GitHub repositories using GitHub's native search engine. Best for finding exact symbols, functions, classes, or specific code patterns.",
"inputSchema": {
- "type": "object",
- "required": [
- "query"
- ],
"properties": {
"order": {
- "type": "string",
"description": "Sort order for results",
"enum": [
"asc",
"desc"
- ]
+ ],
+ "type": "string"
},
"page": {
- "type": "number",
"description": "Page number for pagination (min 1)",
- "minimum": 1
+ "minimum": 1,
+ "type": "number"
},
"perPage": {
- "type": "number",
"description": "Results per page for pagination (min 1, max 100)",
+ "maximum": 100,
"minimum": 1,
- "maximum": 100
+ "type": "number"
},
"query": {
- "type": "string",
- "description": "Search query using GitHub's powerful code search syntax. Examples: 'content:Skill language:Java org:github', 'NOT is:archived language:Python OR language:go', 'repo:github/github-mcp-server'. Supports exact matching, language filters, path filters, and more."
+ "description": "Search query using GitHub's powerful code search syntax. Examples: 'content:Skill language:Java org:github', 'NOT is:archived language:Python OR language:go', 'repo:github/github-mcp-server'. Supports exact matching, language filters, path filters, and more.",
+ "type": "string"
},
"sort": {
- "type": "string",
- "description": "Sort field ('indexed' only)"
+ "description": "Sort field ('indexed' only)",
+ "type": "string"
}
- }
+ },
+ "required": [
+ "query"
+ ],
+ "type": "object"
},
"name": "search_code"
}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/search_issues.snap b/pkg/github/__toolsnaps__/search_issues.snap
index f76a715fb..beaa5b737 100644
--- a/pkg/github/__toolsnaps__/search_issues.snap
+++ b/pkg/github/__toolsnaps__/search_issues.snap
@@ -5,44 +5,39 @@
},
"description": "Search for issues in GitHub repositories using issues search syntax already scoped to is:issue",
"inputSchema": {
- "type": "object",
- "required": [
- "query"
- ],
"properties": {
"order": {
- "type": "string",
"description": "Sort order",
"enum": [
"asc",
"desc"
- ]
+ ],
+ "type": "string"
},
"owner": {
- "type": "string",
- "description": "Optional repository owner. If provided with repo, only issues for this repository are listed."
+ "description": "Optional repository owner. If provided with repo, only issues for this repository are listed.",
+ "type": "string"
},
"page": {
- "type": "number",
"description": "Page number for pagination (min 1)",
- "minimum": 1
+ "minimum": 1,
+ "type": "number"
},
"perPage": {
- "type": "number",
"description": "Results per page for pagination (min 1, max 100)",
+ "maximum": 100,
"minimum": 1,
- "maximum": 100
+ "type": "number"
},
"query": {
- "type": "string",
- "description": "Search query using GitHub issues search syntax"
+ "description": "Search query using GitHub issues search syntax",
+ "type": "string"
},
"repo": {
- "type": "string",
- "description": "Optional repository name. If provided with owner, only issues for this repository are listed."
+ "description": "Optional repository name. If provided with owner, only issues for this repository are listed.",
+ "type": "string"
},
"sort": {
- "type": "string",
"description": "Sort field by number of matches of categories, defaults to best match",
"enum": [
"comments",
@@ -56,9 +51,14 @@
"interactions",
"created",
"updated"
- ]
+ ],
+ "type": "string"
}
- }
+ },
+ "required": [
+ "query"
+ ],
+ "type": "object"
},
"name": "search_issues"
}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/search_orgs.snap b/pkg/github/__toolsnaps__/search_orgs.snap
index 36eb948ae..9670a4be8 100644
--- a/pkg/github/__toolsnaps__/search_orgs.snap
+++ b/pkg/github/__toolsnaps__/search_orgs.snap
@@ -5,44 +5,44 @@
},
"description": "Find GitHub organizations by name, location, or other organization metadata. Ideal for discovering companies, open source foundations, or teams.",
"inputSchema": {
- "type": "object",
- "required": [
- "query"
- ],
"properties": {
"order": {
- "type": "string",
"description": "Sort order",
"enum": [
"asc",
"desc"
- ]
+ ],
+ "type": "string"
},
"page": {
- "type": "number",
"description": "Page number for pagination (min 1)",
- "minimum": 1
+ "minimum": 1,
+ "type": "number"
},
"perPage": {
- "type": "number",
"description": "Results per page for pagination (min 1, max 100)",
+ "maximum": 100,
"minimum": 1,
- "maximum": 100
+ "type": "number"
},
"query": {
- "type": "string",
- "description": "Organization search query. Examples: 'microsoft', 'location:california', 'created:\u003e=2025-01-01'. Search is automatically scoped to type:org."
+ "description": "Organization search query. Examples: 'microsoft', 'location:california', 'created:\u003e=2025-01-01'. Search is automatically scoped to type:org.",
+ "type": "string"
},
"sort": {
- "type": "string",
"description": "Sort field by category",
"enum": [
"followers",
"repositories",
"joined"
- ]
+ ],
+ "type": "string"
}
- }
+ },
+ "required": [
+ "query"
+ ],
+ "type": "object"
},
"name": "search_orgs"
}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/search_pull_requests.snap b/pkg/github/__toolsnaps__/search_pull_requests.snap
index 2013f5c08..05376c006 100644
--- a/pkg/github/__toolsnaps__/search_pull_requests.snap
+++ b/pkg/github/__toolsnaps__/search_pull_requests.snap
@@ -5,44 +5,39 @@
},
"description": "Search for pull requests in GitHub repositories using issues search syntax already scoped to is:pr",
"inputSchema": {
- "type": "object",
- "required": [
- "query"
- ],
"properties": {
"order": {
- "type": "string",
"description": "Sort order",
"enum": [
"asc",
"desc"
- ]
+ ],
+ "type": "string"
},
"owner": {
- "type": "string",
- "description": "Optional repository owner. If provided with repo, only pull requests for this repository are listed."
+ "description": "Optional repository owner. If provided with repo, only pull requests for this repository are listed.",
+ "type": "string"
},
"page": {
- "type": "number",
"description": "Page number for pagination (min 1)",
- "minimum": 1
+ "minimum": 1,
+ "type": "number"
},
"perPage": {
- "type": "number",
"description": "Results per page for pagination (min 1, max 100)",
+ "maximum": 100,
"minimum": 1,
- "maximum": 100
+ "type": "number"
},
"query": {
- "type": "string",
- "description": "Search query using GitHub pull request search syntax"
+ "description": "Search query using GitHub pull request search syntax",
+ "type": "string"
},
"repo": {
- "type": "string",
- "description": "Optional repository name. If provided with owner, only pull requests for this repository are listed."
+ "description": "Optional repository name. If provided with owner, only pull requests for this repository are listed.",
+ "type": "string"
},
"sort": {
- "type": "string",
"description": "Sort field by number of matches of categories, defaults to best match",
"enum": [
"comments",
@@ -56,9 +51,14 @@
"interactions",
"created",
"updated"
- ]
+ ],
+ "type": "string"
}
- }
+ },
+ "required": [
+ "query"
+ ],
+ "type": "object"
},
"name": "search_pull_requests"
}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/search_repositories.snap b/pkg/github/__toolsnaps__/search_repositories.snap
index 881bc3816..8e1cb3171 100644
--- a/pkg/github/__toolsnaps__/search_repositories.snap
+++ b/pkg/github/__toolsnaps__/search_repositories.snap
@@ -5,50 +5,50 @@
},
"description": "Find GitHub repositories by name, description, readme, topics, or other metadata. Perfect for discovering projects, finding examples, or locating specific repositories across GitHub.",
"inputSchema": {
- "type": "object",
- "required": [
- "query"
- ],
"properties": {
"minimal_output": {
- "type": "boolean",
+ "default": true,
"description": "Return minimal repository information (default: true). When false, returns full GitHub API repository objects.",
- "default": true
+ "type": "boolean"
},
"order": {
- "type": "string",
"description": "Sort order",
"enum": [
"asc",
"desc"
- ]
+ ],
+ "type": "string"
},
"page": {
- "type": "number",
"description": "Page number for pagination (min 1)",
- "minimum": 1
+ "minimum": 1,
+ "type": "number"
},
"perPage": {
- "type": "number",
"description": "Results per page for pagination (min 1, max 100)",
+ "maximum": 100,
"minimum": 1,
- "maximum": 100
+ "type": "number"
},
"query": {
- "type": "string",
- "description": "Repository search query. Examples: 'machine learning in:name stars:\u003e1000 language:python', 'topic:react', 'user:facebook'. Supports advanced search syntax for precise filtering."
+ "description": "Repository search query. Examples: 'machine learning in:name stars:\u003e1000 language:python', 'topic:react', 'user:facebook'. Supports advanced search syntax for precise filtering.",
+ "type": "string"
},
"sort": {
- "type": "string",
"description": "Sort repositories by field, defaults to best match",
"enum": [
"stars",
"forks",
"help-wanted-issues",
"updated"
- ]
+ ],
+ "type": "string"
}
- }
+ },
+ "required": [
+ "query"
+ ],
+ "type": "object"
},
"name": "search_repositories"
}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/search_users.snap b/pkg/github/__toolsnaps__/search_users.snap
index 293107696..bed86e8c6 100644
--- a/pkg/github/__toolsnaps__/search_users.snap
+++ b/pkg/github/__toolsnaps__/search_users.snap
@@ -5,44 +5,44 @@
},
"description": "Find GitHub users by username, real name, or other profile information. Useful for locating developers, contributors, or team members.",
"inputSchema": {
- "type": "object",
- "required": [
- "query"
- ],
"properties": {
"order": {
- "type": "string",
"description": "Sort order",
"enum": [
"asc",
"desc"
- ]
+ ],
+ "type": "string"
},
"page": {
- "type": "number",
"description": "Page number for pagination (min 1)",
- "minimum": 1
+ "minimum": 1,
+ "type": "number"
},
"perPage": {
- "type": "number",
"description": "Results per page for pagination (min 1, max 100)",
+ "maximum": 100,
"minimum": 1,
- "maximum": 100
+ "type": "number"
},
"query": {
- "type": "string",
- "description": "User search query. Examples: 'john smith', 'location:seattle', 'followers:\u003e100'. Search is automatically scoped to type:user."
+ "description": "User search query. Examples: 'john smith', 'location:seattle', 'followers:\u003e100'. Search is automatically scoped to type:user.",
+ "type": "string"
},
"sort": {
- "type": "string",
"description": "Sort users by number of followers or repositories, or when the person joined GitHub.",
"enum": [
"followers",
"repositories",
"joined"
- ]
+ ],
+ "type": "string"
}
- }
+ },
+ "required": [
+ "query"
+ ],
+ "type": "object"
},
"name": "search_users"
}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/star_repository.snap b/pkg/github/__toolsnaps__/star_repository.snap
index 165a011bd..3d7088939 100644
--- a/pkg/github/__toolsnaps__/star_repository.snap
+++ b/pkg/github/__toolsnaps__/star_repository.snap
@@ -3,40 +3,34 @@
"title": "Star repository"
},
"description": "Star a GitHub repository",
- "inputSchema": {
- "type": "object",
- "required": [
- "owner",
- "repo"
- ],
- "properties": {
- "owner": {
- "type": "string",
- "description": "Repository owner"
- },
- "repo": {
- "type": "string",
- "description": "Repository name"
- }
- }
- },
- "name": "star_repository",
"icons": [
{
- "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAACG0lEQVRIidWVMWgTYRiGn+/+a21EClGrERRiTWLShrbiUETErDq7u3QRF0WhoKN06uYgKEVx1lGQLjo4OTUJ2FzSpBrEQiCkGYPm7nMwlZBe2rvaxXf6eb//fd//u/+7O0MIJDJz905MnJpvNRufg2oksHli5iwjUgXExUp9La3Vg+isoAGMyiJwBBi11XsQVBaog0zm8plfdGtApEd1LJdEpVL4sZ82UAc/cRf7zAHGPKMPg2j37eB8NnvauGYTODpQ6hjPulAur23tpTd7FePx+JhtIkvAVZ+yraJj48ciH9rtdneYhwCk03NxV5hWNAWSVLykIEngHPs/Rg/4ruiGYG2AbghSMcoXx8l/k3R6Lt4V3STEyAaE2iqTluPk66Arh2wO6Irj5OsGoNVsvIuejEVFmD8Ua+V5zSneAfTvJW83G6vHJ2LjwJV/tH9Wc4p3AYWBKWo1G6vRiZgRuH4ga3S5Vire7+d2jel2s/HxICEKT2ql4qNB3ncEbU9fhTEHGFF56cf7BrhCNmyAi/pqhr1EoQN0iGZIgEyHDUDw1dghNneB1731bR9tsA5yuZwNZPooBd4YT7PVUmGhWios2CpJEV7w5zu0g0xPO3DWAUymZ1OWUO6V3yP6uLpeWPM7XWJq9hIqS6A3ADzl4qZTqPTv2ZUYMd2tjms/NZa+rawXPvkZ76AXfDM1NXPN9eRWxHT3/Df8n/gNrfGxihYBZk0AAAAASUVORK5CYII=",
"mimeType": "image/png",
- "sizes": [
- "24x24"
- ],
+ "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAACG0lEQVRIidWVMWgTYRiGn+/+a21EClGrERRiTWLShrbiUETErDq7u3QRF0WhoKN06uYgKEVx1lGQLjo4OTUJ2FzSpBrEQiCkGYPm7nMwlZBe2rvaxXf6eb//fd//u/+7O0MIJDJz905MnJpvNRufg2oksHli5iwjUgXExUp9La3Vg+isoAGMyiJwBBi11XsQVBaog0zm8plfdGtApEd1LJdEpVL4sZ82UAc/cRf7zAHGPKMPg2j37eB8NnvauGYTODpQ6hjPulAur23tpTd7FePx+JhtIkvAVZ+yraJj48ciH9rtdneYhwCk03NxV5hWNAWSVLykIEngHPs/Rg/4ruiGYG2AbghSMcoXx8l/k3R6Lt4V3STEyAaE2iqTluPk66Arh2wO6Irj5OsGoNVsvIuejEVFmD8Ua+V5zSneAfTvJW83G6vHJ2LjwJV/tH9Wc4p3AYWBKWo1G6vRiZgRuH4ga3S5Vire7+d2jel2s/HxICEKT2ql4qNB3ncEbU9fhTEHGFF56cf7BrhCNmyAi/pqhr1EoQN0iGZIgEyHDUDw1dghNneB1731bR9tsA5yuZwNZPooBd4YT7PVUmGhWios2CpJEV7w5zu0g0xPO3DWAUymZ1OWUO6V3yP6uLpeWPM7XWJq9hIqS6A3ADzl4qZTqPTv2ZUYMd2tjms/NZa+rawXPvkZ76AXfDM1NXPN9eRWxHT3/Df8n/gNrfGxihYBZk0AAAAASUVORK5CYII=",
"theme": "light"
},
{
- "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABLElEQVRIidWVTUrDQBiG3ylddNG9+IdRMC7qpgfoWTyCnqFLF/62UMGNWw/gTdwoiMUzaCtoHxeZ4BQn6SQdEV8IZPG9zzMzCYlUIcARcFilUwW+AUyBd2DrNwSXfOciNnwVeHMEE2A9puCMnzmNBV8BXj2CCbC2LLwFDDzwPAOgVcYwFpRI6khKJe0616akxoJ1zCS9SHp0rgdJ98aYZ2PhT7ksYpC005A0lnQdGS7LHGcqMMB5yVlXzQiYP1orOYkAHwLFxw30l4AfBx1eTUk/+OkA2zUEiY9V9I7vB69mQefPBJ0aAm+nWWH4Q9KNvT/wdMN2DTTJ/lx5ZsAtsOfMJMAV8OnMTYGiBc8JUqd0B3RLZrt2Jk8aImiTfTZ6QVvOOj3baYd2/k++AC+3Yx0GcXS0AAAAAElFTkSuQmCC",
"mimeType": "image/png",
- "sizes": [
- "24x24"
- ],
+ "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABLElEQVRIidWVTUrDQBiG3ylddNG9+IdRMC7qpgfoWTyCnqFLF/62UMGNWw/gTdwoiMUzaCtoHxeZ4BQn6SQdEV8IZPG9zzMzCYlUIcARcFilUwW+AUyBd2DrNwSXfOciNnwVeHMEE2A9puCMnzmNBV8BXj2CCbC2LLwFDDzwPAOgVcYwFpRI6khKJe0616akxoJ1zCS9SHp0rgdJ98aYZ2PhT7ksYpC005A0lnQdGS7LHGcqMMB5yVlXzQiYP1orOYkAHwLFxw30l4AfBx1eTUk/+OkA2zUEiY9V9I7vB69mQefPBJ0aAm+nWWH4Q9KNvT/wdMN2DTTJ/lx5ZsAtsOfMJMAV8OnMTYGiBc8JUqd0B3RLZrt2Jk8aImiTfTZ6QVvOOj3baYd2/k++AC+3Yx0GcXS0AAAAAElFTkSuQmCC",
"theme": "dark"
}
- ]
+ ],
+ "inputSchema": {
+ "properties": {
+ "owner": {
+ "description": "Repository owner",
+ "type": "string"
+ },
+ "repo": {
+ "description": "Repository name",
+ "type": "string"
+ }
+ },
+ "required": [
+ "owner",
+ "repo"
+ ],
+ "type": "object"
+ },
+ "name": "star_repository"
}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/sub_issue_write.snap b/pkg/github/__toolsnaps__/sub_issue_write.snap
index 1c721a2bb..1e4fcceab 100644
--- a/pkg/github/__toolsnaps__/sub_issue_write.snap
+++ b/pkg/github/__toolsnaps__/sub_issue_write.snap
@@ -4,48 +4,48 @@
},
"description": "Add a sub-issue to a parent issue in a GitHub repository.",
"inputSchema": {
- "type": "object",
- "required": [
- "method",
- "owner",
- "repo",
- "issue_number",
- "sub_issue_id"
- ],
"properties": {
"after_id": {
- "type": "number",
- "description": "The ID of the sub-issue to be prioritized after (either after_id OR before_id should be specified)"
+ "description": "The ID of the sub-issue to be prioritized after (either after_id OR before_id should be specified)",
+ "type": "number"
},
"before_id": {
- "type": "number",
- "description": "The ID of the sub-issue to be prioritized before (either after_id OR before_id should be specified)"
+ "description": "The ID of the sub-issue to be prioritized before (either after_id OR before_id should be specified)",
+ "type": "number"
},
"issue_number": {
- "type": "number",
- "description": "The number of the parent issue"
+ "description": "The number of the parent issue",
+ "type": "number"
},
"method": {
- "type": "string",
- "description": "The action to perform on a single sub-issue\nOptions are:\n- 'add' - add a sub-issue to a parent issue in a GitHub repository.\n- 'remove' - remove a sub-issue from a parent issue in a GitHub repository.\n- 'reprioritize' - change the order of sub-issues within a parent issue in a GitHub repository. Use either 'after_id' or 'before_id' to specify the new position.\n\t\t\t\t"
+ "description": "The action to perform on a single sub-issue\nOptions are:\n- 'add' - add a sub-issue to a parent issue in a GitHub repository.\n- 'remove' - remove a sub-issue from a parent issue in a GitHub repository.\n- 'reprioritize' - change the order of sub-issues within a parent issue in a GitHub repository. Use either 'after_id' or 'before_id' to specify the new position.\n\t\t\t\t",
+ "type": "string"
},
"owner": {
- "type": "string",
- "description": "Repository owner"
+ "description": "Repository owner",
+ "type": "string"
},
"replace_parent": {
- "type": "boolean",
- "description": "When true, replaces the sub-issue's current parent issue. Use with 'add' method only."
+ "description": "When true, replaces the sub-issue's current parent issue. Use with 'add' method only.",
+ "type": "boolean"
},
"repo": {
- "type": "string",
- "description": "Repository name"
+ "description": "Repository name",
+ "type": "string"
},
"sub_issue_id": {
- "type": "number",
- "description": "The ID of the sub-issue to add. ID is not the same as issue number"
+ "description": "The ID of the sub-issue to add. ID is not the same as issue number",
+ "type": "number"
}
- }
+ },
+ "required": [
+ "method",
+ "owner",
+ "repo",
+ "issue_number",
+ "sub_issue_id"
+ ],
+ "type": "object"
},
"name": "sub_issue_write"
}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/unstar_repository.snap b/pkg/github/__toolsnaps__/unstar_repository.snap
index 709453650..2bb5d6825 100644
--- a/pkg/github/__toolsnaps__/unstar_repository.snap
+++ b/pkg/github/__toolsnaps__/unstar_repository.snap
@@ -4,21 +4,21 @@
},
"description": "Unstar a GitHub repository",
"inputSchema": {
- "type": "object",
- "required": [
- "owner",
- "repo"
- ],
"properties": {
"owner": {
- "type": "string",
- "description": "Repository owner"
+ "description": "Repository owner",
+ "type": "string"
},
"repo": {
- "type": "string",
- "description": "Repository name"
+ "description": "Repository name",
+ "type": "string"
}
- }
+ },
+ "required": [
+ "owner",
+ "repo"
+ ],
+ "type": "object"
},
"name": "unstar_repository"
}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/update_gist.snap b/pkg/github/__toolsnaps__/update_gist.snap
index a3907a88c..6d5ed100e 100644
--- a/pkg/github/__toolsnaps__/update_gist.snap
+++ b/pkg/github/__toolsnaps__/update_gist.snap
@@ -4,30 +4,30 @@
},
"description": "Update an existing gist",
"inputSchema": {
- "type": "object",
- "required": [
- "gist_id",
- "filename",
- "content"
- ],
"properties": {
"content": {
- "type": "string",
- "description": "Content for the file"
+ "description": "Content for the file",
+ "type": "string"
},
"description": {
- "type": "string",
- "description": "Updated description of the gist"
+ "description": "Updated description of the gist",
+ "type": "string"
},
"filename": {
- "type": "string",
- "description": "Filename to update or create"
+ "description": "Filename to update or create",
+ "type": "string"
},
"gist_id": {
- "type": "string",
- "description": "ID of the gist to update"
+ "description": "ID of the gist to update",
+ "type": "string"
}
- }
+ },
+ "required": [
+ "gist_id",
+ "filename",
+ "content"
+ ],
+ "type": "object"
},
"name": "update_gist"
}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/update_project_item.snap b/pkg/github/__toolsnaps__/update_project_item.snap
index 8f5afaa58..987590741 100644
--- a/pkg/github/__toolsnaps__/update_project_item.snap
+++ b/pkg/github/__toolsnaps__/update_project_item.snap
@@ -4,40 +4,40 @@
},
"description": "Update a specific Project item for a user or org",
"inputSchema": {
- "type": "object",
- "required": [
- "owner_type",
- "owner",
- "project_number",
- "item_id",
- "updated_field"
- ],
"properties": {
"item_id": {
- "type": "number",
- "description": "The unique identifier of the project item. This is not the issue or pull request ID."
+ "description": "The unique identifier of the project item. This is not the issue or pull request ID.",
+ "type": "number"
},
"owner": {
- "type": "string",
- "description": "If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive."
+ "description": "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.",
+ "type": "string"
},
"owner_type": {
- "type": "string",
"description": "Owner type",
"enum": [
"user",
"org"
- ]
+ ],
+ "type": "string"
},
"project_number": {
- "type": "number",
- "description": "The project's number."
+ "description": "The project's number.",
+ "type": "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\"}"
+ "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\"}",
+ "type": "object"
}
- }
+ },
+ "required": [
+ "owner_type",
+ "owner",
+ "project_number",
+ "item_id",
+ "updated_field"
+ ],
+ "type": "object"
},
"name": "update_project_item"
}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/update_pull_request.snap b/pkg/github/__toolsnaps__/update_pull_request.snap
index 6dec2c01f..ef330188f 100644
--- a/pkg/github/__toolsnaps__/update_pull_request.snap
+++ b/pkg/github/__toolsnaps__/update_pull_request.snap
@@ -4,61 +4,61 @@
},
"description": "Update an existing pull request in a GitHub repository.",
"inputSchema": {
- "type": "object",
- "required": [
- "owner",
- "repo",
- "pullNumber"
- ],
"properties": {
"base": {
- "type": "string",
- "description": "New base branch name"
+ "description": "New base branch name",
+ "type": "string"
},
"body": {
- "type": "string",
- "description": "New description"
+ "description": "New description",
+ "type": "string"
},
"draft": {
- "type": "boolean",
- "description": "Mark pull request as draft (true) or ready for review (false)"
+ "description": "Mark pull request as draft (true) or ready for review (false)",
+ "type": "boolean"
},
"maintainer_can_modify": {
- "type": "boolean",
- "description": "Allow maintainer edits"
+ "description": "Allow maintainer edits",
+ "type": "boolean"
},
"owner": {
- "type": "string",
- "description": "Repository owner"
+ "description": "Repository owner",
+ "type": "string"
},
"pullNumber": {
- "type": "number",
- "description": "Pull request number to update"
+ "description": "Pull request number to update",
+ "type": "number"
},
"repo": {
- "type": "string",
- "description": "Repository name"
+ "description": "Repository name",
+ "type": "string"
},
"reviewers": {
- "type": "array",
"description": "GitHub usernames to request reviews from",
"items": {
"type": "string"
- }
+ },
+ "type": "array"
},
"state": {
- "type": "string",
"description": "New state",
"enum": [
"open",
"closed"
- ]
+ ],
+ "type": "string"
},
"title": {
- "type": "string",
- "description": "New title"
+ "description": "New title",
+ "type": "string"
}
- }
+ },
+ "required": [
+ "owner",
+ "repo",
+ "pullNumber"
+ ],
+ "type": "object"
},
"name": "update_pull_request"
}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/update_pull_request_branch.snap b/pkg/github/__toolsnaps__/update_pull_request_branch.snap
index 9be1cb002..a84ac414d 100644
--- a/pkg/github/__toolsnaps__/update_pull_request_branch.snap
+++ b/pkg/github/__toolsnaps__/update_pull_request_branch.snap
@@ -4,30 +4,30 @@
},
"description": "Update the branch of a pull request with the latest changes from the base branch.",
"inputSchema": {
- "type": "object",
- "required": [
- "owner",
- "repo",
- "pullNumber"
- ],
"properties": {
"expectedHeadSha": {
- "type": "string",
- "description": "The expected SHA of the pull request's HEAD ref"
+ "description": "The expected SHA of the pull request's HEAD ref",
+ "type": "string"
},
"owner": {
- "type": "string",
- "description": "Repository owner"
+ "description": "Repository owner",
+ "type": "string"
},
"pullNumber": {
- "type": "number",
- "description": "Pull request number"
+ "description": "Pull request number",
+ "type": "number"
},
"repo": {
- "type": "string",
- "description": "Repository name"
+ "description": "Repository name",
+ "type": "string"
}
- }
+ },
+ "required": [
+ "owner",
+ "repo",
+ "pullNumber"
+ ],
+ "type": "object"
},
"name": "update_pull_request_branch"
}
\ No newline at end of file
diff --git a/pkg/github/actions.go b/pkg/github/actions.go
index 6c7cdc367..d3e5aad8e 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"
@@ -25,9 +26,9 @@ const (
DescriptionRepositoryName = "Repository name"
)
-// FeatureFlagConsolidatedActions is the feature flag that disables individual actions tools
-// in favor of the consolidated actions tools.
-const FeatureFlagConsolidatedActions = "remote_mcp_consolidated_actions"
+// FeatureFlagHoldbackConsolidatedActions is the feature flag that, when enabled, reverts to
+// individual actions tools instead of the consolidated actions tools.
+const FeatureFlagHoldbackConsolidatedActions = "mcp_holdback_consolidated_actions"
// Method constants for consolidated actions tools
const (
@@ -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 {
@@ -115,7 +117,7 @@ func ListWorkflows(t translations.TranslationHelperFunc) inventory.ServerTool {
return utils.NewToolResultText(string(r)), nil, nil
},
)
- tool.FeatureFlagDisable = FeatureFlagConsolidatedActions
+ tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedActions
return tool
}
@@ -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 {
@@ -269,7 +272,7 @@ func ListWorkflowRuns(t translations.TranslationHelperFunc) inventory.ServerTool
return utils.NewToolResultText(string(r)), nil, nil
},
)
- tool.FeatureFlagDisable = FeatureFlagConsolidatedActions
+ tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedActions
return tool
}
@@ -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 {
@@ -381,7 +385,7 @@ func RunWorkflow(t translations.TranslationHelperFunc) inventory.ServerTool {
return utils.NewToolResultText(string(r)), nil, nil
},
)
- tool.FeatureFlagDisable = FeatureFlagConsolidatedActions
+ tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedActions
return tool
}
@@ -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 {
@@ -449,7 +454,7 @@ func GetWorkflowRun(t translations.TranslationHelperFunc) inventory.ServerTool {
return utils.NewToolResultText(string(r)), nil, nil
},
)
- tool.FeatureFlagDisable = FeatureFlagConsolidatedActions
+ tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedActions
return tool
}
@@ -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 {
@@ -527,7 +533,7 @@ func GetWorkflowRunLogs(t translations.TranslationHelperFunc) inventory.ServerTo
return utils.NewToolResultText(string(r)), nil, nil
},
)
- tool.FeatureFlagDisable = FeatureFlagConsolidatedActions
+ tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedActions
return tool
}
@@ -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 {
@@ -627,7 +634,7 @@ func ListWorkflowJobs(t translations.TranslationHelperFunc) inventory.ServerTool
return utils.NewToolResultText(string(r)), nil, nil
},
)
- tool.FeatureFlagDisable = FeatureFlagConsolidatedActions
+ tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedActions
return tool
}
@@ -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 {
@@ -738,7 +746,7 @@ func GetJobLogs(t translations.TranslationHelperFunc) inventory.ServerTool {
return utils.NewToolResultError("Either job_id must be provided for single job logs, or run_id with failed_only=true for failed job logs"), nil, nil
},
)
- tool.FeatureFlagDisable = FeatureFlagConsolidatedActions
+ tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedActions
return tool
}
@@ -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 {
@@ -967,7 +976,7 @@ func RerunWorkflowRun(t translations.TranslationHelperFunc) inventory.ServerTool
return utils.NewToolResultText(string(r)), nil, nil
},
)
- tool.FeatureFlagDisable = FeatureFlagConsolidatedActions
+ tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedActions
return tool
}
@@ -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 {
@@ -1042,7 +1052,7 @@ func RerunFailedJobs(t translations.TranslationHelperFunc) inventory.ServerTool
return utils.NewToolResultText(string(r)), nil, nil
},
)
- tool.FeatureFlagDisable = FeatureFlagConsolidatedActions
+ tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedActions
return tool
}
@@ -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 {
@@ -1119,7 +1130,7 @@ func CancelWorkflowRun(t translations.TranslationHelperFunc) inventory.ServerToo
return utils.NewToolResultText(string(r)), nil, nil
},
)
- tool.FeatureFlagDisable = FeatureFlagConsolidatedActions
+ tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedActions
return tool
}
@@ -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 {
@@ -1199,7 +1211,7 @@ func ListWorkflowRunArtifacts(t translations.TranslationHelperFunc) inventory.Se
return utils.NewToolResultText(string(r)), nil, nil
},
)
- tool.FeatureFlagDisable = FeatureFlagConsolidatedActions
+ tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedActions
return tool
}
@@ -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 {
@@ -1276,7 +1289,7 @@ func DownloadWorkflowRunArtifact(t translations.TranslationHelperFunc) inventory
return utils.NewToolResultText(string(r)), nil, nil
},
)
- tool.FeatureFlagDisable = FeatureFlagConsolidatedActions
+ tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedActions
return tool
}
@@ -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 {
@@ -1352,7 +1366,7 @@ func DeleteWorkflowRunLogs(t translations.TranslationHelperFunc) inventory.Serve
return utils.NewToolResultText(string(r)), nil, nil
},
)
- tool.FeatureFlagDisable = FeatureFlagConsolidatedActions
+ tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedActions
return tool
}
@@ -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 {
@@ -1420,7 +1435,7 @@ func GetWorkflowRunUsage(t translations.TranslationHelperFunc) inventory.ServerT
return utils.NewToolResultText(string(r)), nil, nil
},
)
- tool.FeatureFlagDisable = FeatureFlagConsolidatedActions
+ tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedActions
return tool
}
@@ -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
}
}
@@ -1615,7 +1631,7 @@ Use this tool to list workflows in a repository, or list workflow runs, jobs, an
}
},
)
- tool.FeatureFlagEnable = FeatureFlagConsolidatedActions
+ tool.FeatureFlagDisable = FeatureFlagHoldbackConsolidatedActions
return tool
}
@@ -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 {
@@ -1723,7 +1740,7 @@ Use this tool to get details about individual workflows, workflow runs, jobs, an
}
},
)
- tool.FeatureFlagEnable = FeatureFlagConsolidatedActions
+ tool.FeatureFlagDisable = FeatureFlagHoldbackConsolidatedActions
return tool
}
@@ -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 {
@@ -1841,7 +1859,7 @@ func ActionsRunTrigger(t translations.TranslationHelperFunc) inventory.ServerToo
}
},
)
- tool.FeatureFlagEnable = FeatureFlagConsolidatedActions
+ tool.FeatureFlagDisable = FeatureFlagHoldbackConsolidatedActions
return tool
}
@@ -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 {
@@ -1958,7 +1977,7 @@ For single job logs, provide job_id. For all failed jobs in a run, provide run_i
return utils.NewToolResultError("Either job_id must be provided for single job logs, or run_id with failed_only=true for failed job logs"), nil, nil
},
)
- tool.FeatureFlagEnable = FeatureFlagConsolidatedActions
+ tool.FeatureFlagDisable = FeatureFlagHoldbackConsolidatedActions
return tool
}
@@ -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..b16bbee00 100644
--- a/pkg/github/dependencies.go
+++ b/pkg/github/dependencies.go
@@ -3,11 +3,18 @@ package github
import (
"context"
"errors"
+ "fmt"
+ "net/http"
+ "os"
+ ghcontext "github.com/github/github-mcp-server/pkg/context"
+ "github.com/github/github-mcp-server/pkg/http/transport"
"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"
+ "github.com/github/github-mcp-server/pkg/utils"
gogithub "github.com/google/go-github/v79/github"
"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/shurcooL/githubv4"
@@ -20,6 +27,14 @@ type depsContextKey struct{}
// ErrDepsNotInContext is returned when ToolDependencies is not found in context.
var ErrDepsNotInContext = errors.New("ToolDependencies not found in context; use ContextWithDeps to inject")
+func InjectDepsMiddleware(deps ToolDependencies) mcp.Middleware {
+ return func(next mcp.MethodHandler) mcp.MethodHandler {
+ return func(ctx context.Context, method string, req mcp.Request) (result mcp.Result, err error) {
+ return next(ContextWithDeps(ctx, deps), method, req)
+ }
+ }
+}
+
// ContextWithDeps returns a new context with the ToolDependencies stored in it.
// This is used to inject dependencies at request time rather than at registration time,
// avoiding expensive closure creation during server initialization.
@@ -66,16 +81,19 @@ type ToolDependencies interface {
GetRawClient(ctx context.Context) (*raw.Client, error)
// GetRepoAccessCache returns the lockdown mode repo access cache
- GetRepoAccessCache() *lockdown.RepoAccessCache
+ GetRepoAccessCache(ctx context.Context) (*lockdown.RepoAccessCache, error)
// GetT returns the translation helper function
GetT() translations.TranslationHelperFunc
// GetFlags returns feature flags
- GetFlags() FeatureFlags
+ GetFlags(ctx context.Context) FeatureFlags
// GetContentWindowSize returns the content window size for log truncation
GetContentWindowSize() int
+
+ // IsFeatureEnabled checks if a feature flag is enabled.
+ IsFeatureEnabled(ctx context.Context, flagName string) bool
}
// BaseDeps is the standard implementation of ToolDependencies for the local server.
@@ -92,8 +110,14 @@ type BaseDeps struct {
T translations.TranslationHelperFunc
Flags FeatureFlags
ContentWindowSize int
+
+ // Feature flag checker for runtime checks
+ featureChecker inventory.FeatureFlagChecker
}
+// Compile-time assertion to verify that BaseDeps implements the ToolDependencies interface.
+var _ ToolDependencies = (*BaseDeps)(nil)
+
// NewBaseDeps creates a BaseDeps with the provided clients and configuration.
func NewBaseDeps(
client *gogithub.Client,
@@ -103,6 +127,7 @@ func NewBaseDeps(
t translations.TranslationHelperFunc,
flags FeatureFlags,
contentWindowSize int,
+ featureChecker inventory.FeatureFlagChecker,
) *BaseDeps {
return &BaseDeps{
Client: client,
@@ -112,6 +137,7 @@ func NewBaseDeps(
T: t,
Flags: flags,
ContentWindowSize: contentWindowSize,
+ featureChecker: featureChecker,
}
}
@@ -131,28 +157,60 @@ func (d BaseDeps) GetRawClient(_ context.Context) (*raw.Client, error) {
}
// GetRepoAccessCache implements ToolDependencies.
-func (d BaseDeps) GetRepoAccessCache() *lockdown.RepoAccessCache { return d.RepoAccessCache }
+func (d BaseDeps) GetRepoAccessCache(_ context.Context) (*lockdown.RepoAccessCache, error) {
+ return d.RepoAccessCache, nil
+}
// GetT implements ToolDependencies.
func (d BaseDeps) GetT() translations.TranslationHelperFunc { return d.T }
// GetFlags implements ToolDependencies.
-func (d BaseDeps) GetFlags() FeatureFlags { return d.Flags }
+func (d BaseDeps) GetFlags(_ context.Context) FeatureFlags { return d.Flags }
// GetContentWindowSize implements ToolDependencies.
func (d BaseDeps) GetContentWindowSize() int { return d.ContentWindowSize }
+// IsFeatureEnabled checks if a feature flag is enabled.
+// Returns false if the feature checker is nil, flag name is empty, or an error occurs.
+// This allows tools to conditionally change behavior based on feature flags.
+func (d BaseDeps) IsFeatureEnabled(ctx context.Context, flagName string) bool {
+ if d.featureChecker == nil || flagName == "" {
+ return false
+ }
+
+ enabled, err := d.featureChecker(ctx, flagName)
+ if err != nil {
+ // Log error but don't fail the tool - treat as disabled
+ fmt.Fprintf(os.Stderr, "Feature flag check error for %q: %v\n", flagName, err)
+ return false
+ }
+
+ return enabled
+}
+
// NewTool creates a ServerTool that retrieves ToolDependencies from context at call time.
// This avoids creating closures at registration time, which is important for performance
// in servers that create a new server instance per request (like the remote server).
//
// 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 +218,174 @@ 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
+}
+
+type RequestDeps struct {
+ // Static dependencies
+ apiHosts utils.APIHostResolver
+ version string
+ lockdownMode bool
+ RepoAccessOpts []lockdown.RepoAccessOption
+ T translations.TranslationHelperFunc
+ ContentWindowSize int
+
+ // Feature flag checker for runtime checks
+ featureChecker inventory.FeatureFlagChecker
+}
+
+// NewRequestDeps creates a RequestDeps with the provided clients and configuration.
+func NewRequestDeps(
+ apiHosts utils.APIHostResolver,
+ version string,
+ lockdownMode bool,
+ repoAccessOpts []lockdown.RepoAccessOption,
+ t translations.TranslationHelperFunc,
+ contentWindowSize int,
+ featureChecker inventory.FeatureFlagChecker,
+) *RequestDeps {
+ return &RequestDeps{
+ apiHosts: apiHosts,
+ version: version,
+ lockdownMode: lockdownMode,
+ RepoAccessOpts: repoAccessOpts,
+ T: t,
+ ContentWindowSize: contentWindowSize,
+ featureChecker: featureChecker,
+ }
+}
+
+// GetClient implements ToolDependencies.
+func (d *RequestDeps) GetClient(ctx context.Context) (*gogithub.Client, error) {
+ // extract the token from the context
+ tokenInfo, ok := ghcontext.GetTokenInfo(ctx)
+ if !ok {
+ return nil, fmt.Errorf("no token info in context")
+ }
+ token := tokenInfo.Token
+
+ baseRestURL, err := d.apiHosts.BaseRESTURL(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get base REST URL: %w", err)
+ }
+ uploadURL, err := d.apiHosts.UploadURL(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get upload URL: %w", err)
+ }
+
+ // Construct REST client
+ restClient := gogithub.NewClient(nil).WithAuthToken(token)
+ restClient.UserAgent = fmt.Sprintf("github-mcp-server/%s", d.version)
+ restClient.BaseURL = baseRestURL
+ restClient.UploadURL = uploadURL
+ return restClient, nil
+}
+
+// GetGQLClient implements ToolDependencies.
+func (d *RequestDeps) GetGQLClient(ctx context.Context) (*githubv4.Client, error) {
+ // extract the token from the context
+ tokenInfo, ok := ghcontext.GetTokenInfo(ctx)
+ if !ok {
+ return nil, fmt.Errorf("no token info in context")
+ }
+ token := tokenInfo.Token
+
+ // Construct GraphQL client
+ // We use NewEnterpriseClient unconditionally since we already parsed the API host
+ // Wrap transport with GraphQLFeaturesTransport to inject feature flags from context,
+ // matching the transport chain used by the remote server.
+ gqlHTTPClient := &http.Client{
+ Transport: &transport.BearerAuthTransport{
+ Transport: &transport.GraphQLFeaturesTransport{
+ Transport: http.DefaultTransport,
+ },
+ Token: token,
+ },
+ }
+
+ graphqlURL, err := d.apiHosts.GraphqlURL(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get GraphQL URL: %w", err)
+ }
+
+ gqlClient := githubv4.NewEnterpriseClient(graphqlURL.String(), gqlHTTPClient)
+ return gqlClient, nil
+}
+
+// GetRawClient implements ToolDependencies.
+func (d *RequestDeps) GetRawClient(ctx context.Context) (*raw.Client, error) {
+ client, err := d.GetClient(ctx)
+ if err != nil {
+ return nil, err
+ }
+
+ rawURL, err := d.apiHosts.RawURL(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get Raw URL: %w", err)
+ }
+
+ rawClient := raw.NewClient(client, rawURL)
+
+ return rawClient, nil
+}
+
+// GetRepoAccessCache implements ToolDependencies.
+func (d *RequestDeps) GetRepoAccessCache(ctx context.Context) (*lockdown.RepoAccessCache, error) {
+ if !d.lockdownMode {
+ return nil, nil
+ }
+
+ gqlClient, err := d.GetGQLClient(ctx)
+ if err != nil {
+ return nil, err
+ }
+
+ // Create repo access cache
+ instance := lockdown.GetInstance(gqlClient, d.RepoAccessOpts...)
+ return instance, nil
+}
+
+// GetT implements ToolDependencies.
+func (d *RequestDeps) GetT() translations.TranslationHelperFunc { return d.T }
+
+// GetFlags implements ToolDependencies.
+func (d *RequestDeps) GetFlags(ctx context.Context) FeatureFlags {
+ return FeatureFlags{
+ LockdownMode: d.lockdownMode && ghcontext.IsLockdownMode(ctx),
+ InsidersMode: ghcontext.IsInsidersMode(ctx),
+ }
+}
+
+// GetContentWindowSize implements ToolDependencies.
+func (d *RequestDeps) GetContentWindowSize() int { return d.ContentWindowSize }
+
+// IsFeatureEnabled checks if a feature flag is enabled.
+func (d *RequestDeps) IsFeatureEnabled(ctx context.Context, flagName string) bool {
+ if d.featureChecker == nil || flagName == "" {
+ return false
+ }
+
+ enabled, err := d.featureChecker(ctx, flagName)
+ if err != nil {
+ // Log error but don't fail the tool - treat as disabled
+ fmt.Fprintf(os.Stderr, "Feature flag check error for %q: %v\n", flagName, err)
+ return false
+ }
+
+ return enabled
}
diff --git a/pkg/github/dependencies_test.go b/pkg/github/dependencies_test.go
new file mode 100644
index 000000000..d13160d4c
--- /dev/null
+++ b/pkg/github/dependencies_test.go
@@ -0,0 +1,108 @@
+package github_test
+
+import (
+ "context"
+ "errors"
+ "testing"
+
+ "github.com/github/github-mcp-server/pkg/github"
+ "github.com/github/github-mcp-server/pkg/translations"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestIsFeatureEnabled_WithEnabledFlag(t *testing.T) {
+ t.Parallel()
+
+ // Create a feature checker that returns true for "test_flag"
+ checker := func(_ context.Context, flagName string) (bool, error) {
+ return flagName == "test_flag", nil
+ }
+
+ // Create deps with the checker using NewBaseDeps
+ deps := github.NewBaseDeps(
+ nil, // client
+ nil, // gqlClient
+ nil, // rawClient
+ nil, // repoAccessCache
+ translations.NullTranslationHelper,
+ github.FeatureFlags{},
+ 0, // contentWindowSize
+ checker, // featureChecker
+ )
+
+ // Test enabled flag
+ result := deps.IsFeatureEnabled(context.Background(), "test_flag")
+ assert.True(t, result, "Expected test_flag to be enabled")
+
+ // Test disabled flag
+ result = deps.IsFeatureEnabled(context.Background(), "other_flag")
+ assert.False(t, result, "Expected other_flag to be disabled")
+}
+
+func TestIsFeatureEnabled_WithoutChecker(t *testing.T) {
+ t.Parallel()
+
+ // Create deps without feature checker (nil)
+ deps := github.NewBaseDeps(
+ nil, // client
+ nil, // gqlClient
+ nil, // rawClient
+ nil, // repoAccessCache
+ translations.NullTranslationHelper,
+ github.FeatureFlags{},
+ 0, // contentWindowSize
+ nil, // featureChecker (nil)
+ )
+
+ // Should return false when checker is nil
+ result := deps.IsFeatureEnabled(context.Background(), "any_flag")
+ assert.False(t, result, "Expected false when checker is nil")
+}
+
+func TestIsFeatureEnabled_EmptyFlagName(t *testing.T) {
+ t.Parallel()
+
+ // Create a feature checker
+ checker := func(_ context.Context, _ string) (bool, error) {
+ return true, nil
+ }
+
+ deps := github.NewBaseDeps(
+ nil, // client
+ nil, // gqlClient
+ nil, // rawClient
+ nil, // repoAccessCache
+ translations.NullTranslationHelper,
+ github.FeatureFlags{},
+ 0, // contentWindowSize
+ checker, // featureChecker
+ )
+
+ // Should return false for empty flag name
+ result := deps.IsFeatureEnabled(context.Background(), "")
+ assert.False(t, result, "Expected false for empty flag name")
+}
+
+func TestIsFeatureEnabled_CheckerError(t *testing.T) {
+ t.Parallel()
+
+ // Create a feature checker that returns an error
+ checker := func(_ context.Context, _ string) (bool, error) {
+ return false, errors.New("checker error")
+ }
+
+ deps := github.NewBaseDeps(
+ nil, // client
+ nil, // gqlClient
+ nil, // rawClient
+ nil, // repoAccessCache
+ translations.NullTranslationHelper,
+ github.FeatureFlags{},
+ 0, // contentWindowSize
+ checker, // featureChecker
+ )
+
+ // Should return false and log error (not crash)
+ result := deps.IsFeatureEnabled(context.Background(), "error_flag")
+ assert.False(t, result, "Expected false when checker returns error")
+}
diff --git a/pkg/github/deprecated_tool_aliases.go b/pkg/github/deprecated_tool_aliases.go
index 4abdca14d..4415731fb 100644
--- a/pkg/github/deprecated_tool_aliases.go
+++ b/pkg/github/deprecated_tool_aliases.go
@@ -11,4 +11,32 @@ package github
// "create_pr": "pull_request_create",
var DeprecatedToolAliases = map[string]string{
// Add entries as tools are renamed
+ // Actions tools consolidated
+ "list_workflows": "actions_list",
+ "list_workflow_runs": "actions_list",
+ "list_workflow_jobs": "actions_list",
+ "list_workflow_run_artifacts": "actions_list",
+ "get_workflow": "actions_get",
+ "get_workflow_run": "actions_get",
+ "get_workflow_job": "actions_get",
+ "get_workflow_run_usage": "actions_get",
+ "get_workflow_run_logs": "actions_get",
+ "get_workflow_job_logs": "actions_get",
+ "download_workflow_run_artifact": "actions_get",
+ "run_workflow": "actions_run_trigger",
+ "rerun_workflow_run": "actions_run_trigger",
+ "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/dynamic_tools_test.go b/pkg/github/dynamic_tools_test.go
index 8d12b78c2..3e63c5d7b 100644
--- a/pkg/github/dynamic_tools_test.go
+++ b/pkg/github/dynamic_tools_test.go
@@ -25,9 +25,10 @@ func createDynamicRequest(args map[string]any) *mcp.CallToolRequest {
func TestDynamicTools_ListAvailableToolsets(t *testing.T) {
// Build a registry with no toolsets enabled (dynamic mode)
- reg := NewInventory(translations.NullTranslationHelper).
+ reg, err := NewInventory(translations.NullTranslationHelper).
WithToolsets([]string{}).
Build()
+ require.NoError(t, err)
// Create a mock server
server := mcp.NewServer(&mcp.Implementation{Name: "test"}, nil)
@@ -73,9 +74,10 @@ func TestDynamicTools_ListAvailableToolsets(t *testing.T) {
func TestDynamicTools_GetToolsetTools(t *testing.T) {
// Build a registry with no toolsets enabled (dynamic mode)
- reg := NewInventory(translations.NullTranslationHelper).
+ reg, err := NewInventory(translations.NullTranslationHelper).
WithToolsets([]string{}).
Build()
+ require.NoError(t, err)
// Create a mock server
server := mcp.NewServer(&mcp.Implementation{Name: "test"}, nil)
@@ -122,9 +124,10 @@ func TestDynamicTools_GetToolsetTools(t *testing.T) {
func TestDynamicTools_EnableToolset(t *testing.T) {
// Build a registry with no toolsets enabled (dynamic mode)
- reg := NewInventory(translations.NullTranslationHelper).
+ reg, err := NewInventory(translations.NullTranslationHelper).
WithToolsets([]string{}).
Build()
+ require.NoError(t, err)
// Create a mock server
server := mcp.NewServer(&mcp.Implementation{Name: "test"}, nil)
@@ -133,7 +136,7 @@ func TestDynamicTools_EnableToolset(t *testing.T) {
deps := DynamicToolDependencies{
Server: server,
Inventory: reg,
- ToolDeps: NewBaseDeps(nil, nil, nil, nil, translations.NullTranslationHelper, FeatureFlags{}, 0),
+ ToolDeps: NewBaseDeps(nil, nil, nil, nil, translations.NullTranslationHelper, FeatureFlags{}, 0, nil),
T: translations.NullTranslationHelper,
}
@@ -170,9 +173,10 @@ func TestDynamicTools_EnableToolset(t *testing.T) {
func TestDynamicTools_EnableToolset_InvalidToolset(t *testing.T) {
// Build a registry with no toolsets enabled (dynamic mode)
- reg := NewInventory(translations.NullTranslationHelper).
+ reg, err := NewInventory(translations.NullTranslationHelper).
WithToolsets([]string{}).
Build()
+ require.NoError(t, err)
// Create a mock server
server := mcp.NewServer(&mcp.Implementation{Name: "test"}, nil)
@@ -203,7 +207,8 @@ func TestDynamicTools_EnableToolset_InvalidToolset(t *testing.T) {
func TestDynamicTools_ToolsetsEnum(t *testing.T) {
// Build a registry
- reg := NewInventory(translations.NullTranslationHelper).Build()
+ reg, err := NewInventory(translations.NullTranslationHelper).Build()
+ require.NoError(t, err)
// Get tools to verify they have proper enum values
tools := DynamicTools(reg)
diff --git a/pkg/github/feature_flags.go b/pkg/github/feature_flags.go
index 047042e44..fd06a659b 100644
--- a/pkg/github/feature_flags.go
+++ b/pkg/github/feature_flags.go
@@ -3,4 +3,5 @@ package github
// FeatureFlags defines runtime feature toggles that adjust tool behavior.
type FeatureFlags struct {
LockdownMode bool
+ InsidersMode bool
}
diff --git a/pkg/github/feature_flags_test.go b/pkg/github/feature_flags_test.go
new file mode 100644
index 000000000..2f0a435c9
--- /dev/null
+++ b/pkg/github/feature_flags_test.go
@@ -0,0 +1,198 @@
+package github
+
+import (
+ "context"
+ "encoding/json"
+ "testing"
+
+ "github.com/github/github-mcp-server/pkg/translations"
+ "github.com/modelcontextprotocol/go-sdk/mcp"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/github/github-mcp-server/pkg/inventory"
+ "github.com/github/github-mcp-server/pkg/scopes"
+ "github.com/github/github-mcp-server/pkg/utils"
+)
+
+// RemoteMCPEnthusiasticGreeting is a dummy test feature flag .
+const RemoteMCPEnthusiasticGreeting = "remote_mcp_enthusiastic_greeting"
+
+// FeatureChecker is an interface for checking if a feature flag is enabled.
+type FeatureChecker interface {
+ // IsFeatureEnabled checks if a feature flag is enabled.
+ IsFeatureEnabled(ctx context.Context, flagName string) bool
+}
+
+// HelloWorld returns a simple greeting tool that demonstrates feature flag conditional behavior.
+// This tool is for testing and demonstration purposes only.
+func HelloWorldTool(t translations.TranslationHelperFunc) inventory.ServerTool {
+ return NewTool(
+ ToolsetMetadataContext, // Use existing "context" toolset
+ mcp.Tool{
+ Name: "hello_world",
+ Description: t("TOOL_HELLO_WORLD_DESCRIPTION", "A simple greeting tool that demonstrates feature flag conditional behavior"),
+ Annotations: &mcp.ToolAnnotations{
+ Title: t("TOOL_HELLO_WORLD_TITLE", "Hello World"),
+ ReadOnlyHint: true,
+ },
+ },
+ []scopes.Scope{},
+ func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, _ map[string]any) (*mcp.CallToolResult, any, error) {
+
+ // Check feature flag to determine greeting style
+ greeting := "Hello, world!"
+ if deps.IsFeatureEnabled(ctx, RemoteMCPEnthusiasticGreeting) {
+ greeting += " Welcome to the future of MCP! 🎉"
+ }
+ if deps.GetFlags(ctx).InsidersMode {
+ greeting += " Experimental features are enabled! 🚀"
+ }
+
+ // Build response
+ response := map[string]any{
+ "greeting": greeting,
+ }
+
+ jsonBytes, err := json.Marshal(response)
+ if err != nil {
+ return utils.NewToolResultError("failed to marshal response"), nil, nil
+ }
+
+ return utils.NewToolResultText(string(jsonBytes)), nil, nil
+ },
+ )
+}
+
+func TestHelloWorld_ConditionalBehavior_Featureflag(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ featureFlagEnabled bool
+ inputName string
+ expectedGreeting string
+ }{
+ {
+ name: "Feature flag disabled - default greeting",
+ featureFlagEnabled: false,
+ expectedGreeting: "Hello, world!",
+ },
+ {
+ name: "Feature flag enabled - enthusiastic greeting",
+ featureFlagEnabled: true,
+ expectedGreeting: "Hello, world! Welcome to the future of MCP! 🎉",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ // Create feature checker based on test case
+ checker := func(_ context.Context, flagName string) (bool, error) {
+ if flagName == RemoteMCPEnthusiasticGreeting {
+ return tt.featureFlagEnabled, nil
+ }
+ return false, nil
+ }
+
+ // Create deps with the checker
+ deps := NewBaseDeps(
+ nil, nil, nil, nil,
+ translations.NullTranslationHelper,
+ FeatureFlags{},
+ 0,
+ checker,
+ )
+
+ // Get the tool and its handler
+ tool := HelloWorldTool(translations.NullTranslationHelper)
+ handler := tool.Handler(deps)
+
+ // Call the handler with deps in context
+ ctx := ContextWithDeps(context.Background(), deps)
+ result, err := handler(ctx, &mcp.CallToolRequest{
+ Params: &mcp.CallToolParamsRaw{
+ Arguments: json.RawMessage(`{}`),
+ },
+ })
+ require.NoError(t, err)
+ require.NotNil(t, result)
+ require.Len(t, result.Content, 1)
+
+ // Parse the response - should be TextContent
+ textContent, ok := result.Content[0].(*mcp.TextContent)
+ require.True(t, ok, "expected content to be TextContent")
+
+ var response map[string]any
+ err = json.Unmarshal([]byte(textContent.Text), &response)
+ require.NoError(t, err)
+
+ // Verify the greeting matches expected based on feature flag
+ assert.Equal(t, tt.expectedGreeting, response["greeting"])
+ })
+ }
+}
+
+func TestHelloWorld_ConditionalBehavior_Config(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ insidersMode bool
+ expectedGreeting string
+ }{
+ {
+ name: "Experimental disabled - default greeting",
+ insidersMode: false,
+ expectedGreeting: "Hello, world!",
+ },
+ {
+ name: "Experimental enabled - experimental greeting",
+ insidersMode: true,
+ expectedGreeting: "Hello, world! Experimental features are enabled! 🚀",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ // Create deps with the checker
+ deps := NewBaseDeps(
+ nil, nil, nil, nil,
+ translations.NullTranslationHelper,
+ FeatureFlags{InsidersMode: tt.insidersMode},
+ 0,
+ nil,
+ )
+
+ // Get the tool and its handler
+ tool := HelloWorldTool(translations.NullTranslationHelper)
+ handler := tool.Handler(deps)
+
+ // Call the handler with deps in context
+ ctx := ContextWithDeps(context.Background(), deps)
+ result, err := handler(ctx, &mcp.CallToolRequest{
+ Params: &mcp.CallToolParamsRaw{
+ Arguments: json.RawMessage(`{}`),
+ },
+ })
+ require.NoError(t, err)
+ require.NotNil(t, result)
+ require.Len(t, result.Content, 1)
+
+ // Parse the response - should be TextContent
+ textContent, ok := result.Content[0].(*mcp.TextContent)
+ require.True(t, ok, "expected content to be TextContent")
+
+ var response map[string]any
+ err = json.Unmarshal([]byte(textContent.Text), &response)
+ require.NoError(t, err)
+
+ // Verify the greeting matches expected based on feature flag
+ assert.Equal(t, tt.expectedGreeting, response["greeting"])
+ })
+ }
+}
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..8f596c099 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
@@ -72,6 +72,7 @@ const (
PutReposPullsMergeByOwnerByRepoByPullNumber = "PUT /repos/{owner}/{repo}/pulls/{pull_number}/merge"
PutReposPullsUpdateBranchByOwnerByRepoByPullNumber = "PUT /repos/{owner}/{repo}/pulls/{pull_number}/update-branch"
PostReposPullsRequestedReviewersByOwnerByRepoByPullNumber = "POST /repos/{owner}/{repo}/pulls/{pull_number}/requested_reviewers"
+ PostReposPullsCommentsByOwnerByRepoByPullNumber = "POST /repos/{owner}/{repo}/pulls/{pull_number}/comments"
// Notifications endpoints
GetNotifications = "GET /notifications"
@@ -118,6 +119,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 +134,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 +143,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 +435,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 +591,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/instructions_test.go b/pkg/github/instructions_test.go
deleted file mode 100644
index b8ad2ba8c..000000000
--- a/pkg/github/instructions_test.go
+++ /dev/null
@@ -1,186 +0,0 @@
-package github
-
-import (
- "os"
- "strings"
- "testing"
-)
-
-func TestGenerateInstructions(t *testing.T) {
- tests := []struct {
- name string
- enabledToolsets []string
- expectedEmpty bool
- }{
- {
- name: "empty toolsets",
- enabledToolsets: []string{},
- expectedEmpty: false,
- },
- {
- name: "only context toolset",
- enabledToolsets: []string{"context"},
- expectedEmpty: false,
- },
- {
- name: "pull requests toolset",
- enabledToolsets: []string{"pull_requests"},
- expectedEmpty: false,
- },
- {
- name: "issues toolset",
- enabledToolsets: []string{"issues"},
- expectedEmpty: false,
- },
- {
- name: "discussions toolset",
- enabledToolsets: []string{"discussions"},
- expectedEmpty: false,
- },
- {
- name: "multiple toolsets (context + pull_requests)",
- enabledToolsets: []string{"context", "pull_requests"},
- expectedEmpty: false,
- },
- {
- name: "multiple toolsets (issues + pull_requests)",
- enabledToolsets: []string{"issues", "pull_requests"},
- expectedEmpty: false,
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- result := GenerateInstructions(tt.enabledToolsets)
-
- if tt.expectedEmpty {
- if result != "" {
- t.Errorf("Expected empty instructions but got: %s", result)
- }
- } else {
- if result == "" {
- t.Errorf("Expected non-empty instructions but got empty result")
- }
- }
- })
- }
-}
-
-func TestGenerateInstructionsWithDisableFlag(t *testing.T) {
- tests := []struct {
- name string
- disableEnvValue string
- enabledToolsets []string
- expectedEmpty bool
- }{
- {
- name: "DISABLE_INSTRUCTIONS=true returns empty",
- disableEnvValue: "true",
- enabledToolsets: []string{"context", "issues", "pull_requests"},
- expectedEmpty: true,
- },
- {
- name: "DISABLE_INSTRUCTIONS=false returns normal instructions",
- disableEnvValue: "false",
- enabledToolsets: []string{"context"},
- expectedEmpty: false,
- },
- {
- name: "DISABLE_INSTRUCTIONS unset returns normal instructions",
- disableEnvValue: "",
- enabledToolsets: []string{"issues"},
- expectedEmpty: false,
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- // Save original env value
- originalValue := os.Getenv("DISABLE_INSTRUCTIONS")
- defer func() {
- if originalValue == "" {
- os.Unsetenv("DISABLE_INSTRUCTIONS")
- } else {
- os.Setenv("DISABLE_INSTRUCTIONS", originalValue)
- }
- }()
-
- // Set test env value
- if tt.disableEnvValue == "" {
- os.Unsetenv("DISABLE_INSTRUCTIONS")
- } else {
- os.Setenv("DISABLE_INSTRUCTIONS", tt.disableEnvValue)
- }
-
- result := GenerateInstructions(tt.enabledToolsets)
-
- if tt.expectedEmpty {
- if result != "" {
- t.Errorf("Expected empty instructions but got: %s", result)
- }
- } else {
- if result == "" {
- t.Errorf("Expected non-empty instructions but got empty result")
- }
- }
- })
- }
-}
-
-func TestGetToolsetInstructions(t *testing.T) {
- tests := []struct {
- toolset string
- expectedEmpty bool
- enabledToolsets []string
- expectedToContain string
- notExpectedToContain string
- }{
- {
- toolset: "pull_requests",
- expectedEmpty: false,
- enabledToolsets: []string{"pull_requests", "repos"},
- expectedToContain: "pull_request_template.md",
- },
- {
- toolset: "pull_requests",
- expectedEmpty: false,
- enabledToolsets: []string{"pull_requests"},
- notExpectedToContain: "pull_request_template.md",
- },
- {
- toolset: "issues",
- expectedEmpty: false,
- },
- {
- toolset: "discussions",
- expectedEmpty: false,
- },
- {
- toolset: "nonexistent",
- expectedEmpty: true,
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.toolset, func(t *testing.T) {
- result := getToolsetInstructions(tt.toolset, tt.enabledToolsets)
- if tt.expectedEmpty {
- if result != "" {
- t.Errorf("Expected empty result for toolset '%s', but got: %s", tt.toolset, result)
- }
- } else {
- if result == "" {
- t.Errorf("Expected non-empty result for toolset '%s', but got empty", tt.toolset)
- }
- }
-
- if tt.expectedToContain != "" && !strings.Contains(result, tt.expectedToContain) {
- t.Errorf("Expected result to contain '%s' for toolset '%s', but it did not. Result: %s", tt.expectedToContain, tt.toolset, result)
- }
-
- if tt.notExpectedToContain != "" && strings.Contains(result, tt.notExpectedToContain) {
- t.Errorf("Did not expect result to contain '%s' for toolset '%s', but it did. Result: %s", tt.notExpectedToContain, tt.toolset, result)
- }
- })
- }
-}
diff --git a/pkg/github/issues.go b/pkg/github/issues.go
index f06dc2d9d..c4cc54175 100644
--- a/pkg/github/issues.go
+++ b/pkg/github/issues.go
@@ -9,11 +9,12 @@ import (
"strings"
"time"
+ ghcontext "github.com/github/github-mcp-server/pkg/context"
ghErrors "github.com/github/github-mcp-server/pkg/errors"
"github.com/github/github-mcp-server/pkg/inventory"
- "github.com/github/github-mcp-server/pkg/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 {
@@ -310,13 +312,13 @@ Options are:
switch method {
case "get":
- result, err := GetIssue(ctx, client, deps.GetRepoAccessCache(), owner, repo, issueNumber, deps.GetFlags())
+ result, err := GetIssue(ctx, client, deps, owner, repo, issueNumber)
return result, nil, err
case "get_comments":
- result, err := GetIssueComments(ctx, client, deps.GetRepoAccessCache(), owner, repo, issueNumber, pagination, deps.GetFlags())
+ result, err := GetIssueComments(ctx, client, deps, owner, repo, issueNumber, pagination)
return result, nil, err
case "get_sub_issues":
- result, err := GetSubIssues(ctx, client, deps.GetRepoAccessCache(), owner, repo, issueNumber, pagination, deps.GetFlags())
+ result, err := GetSubIssues(ctx, client, deps, owner, repo, issueNumber, pagination)
return result, nil, err
case "get_labels":
result, err := GetIssueLabels(ctx, gqlClient, owner, repo, issueNumber)
@@ -327,7 +329,13 @@ Options are:
})
}
-func GetIssue(ctx context.Context, client *github.Client, cache *lockdown.RepoAccessCache, owner string, repo string, issueNumber int, flags FeatureFlags) (*mcp.CallToolResult, error) {
+func GetIssue(ctx context.Context, client *github.Client, deps ToolDependencies, owner string, repo string, issueNumber int) (*mcp.CallToolResult, error) {
+ cache, err := deps.GetRepoAccessCache(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get repo access cache: %w", err)
+ }
+ flags := deps.GetFlags(ctx)
+
issue, resp, err := client.Issues.Get(ctx, owner, repo, issueNumber)
if err != nil {
return nil, fmt.Errorf("failed to get issue: %w", err)
@@ -376,7 +384,13 @@ func GetIssue(ctx context.Context, client *github.Client, cache *lockdown.RepoAc
return utils.NewToolResultText(string(r)), nil
}
-func GetIssueComments(ctx context.Context, client *github.Client, cache *lockdown.RepoAccessCache, owner string, repo string, issueNumber int, pagination PaginationParams, flags FeatureFlags) (*mcp.CallToolResult, error) {
+func GetIssueComments(ctx context.Context, client *github.Client, deps ToolDependencies, owner string, repo string, issueNumber int, pagination PaginationParams) (*mcp.CallToolResult, error) {
+ cache, err := deps.GetRepoAccessCache(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get repo access cache: %w", err)
+ }
+ flags := deps.GetFlags(ctx)
+
opts := &github.IssueListCommentsOptions{
ListOptions: github.ListOptions{
Page: pagination.Page,
@@ -430,7 +444,13 @@ func GetIssueComments(ctx context.Context, client *github.Client, cache *lockdow
return utils.NewToolResultText(string(r)), nil
}
-func GetSubIssues(ctx context.Context, client *github.Client, cache *lockdown.RepoAccessCache, owner string, repo string, issueNumber int, pagination PaginationParams, featureFlags FeatureFlags) (*mcp.CallToolResult, error) {
+func GetSubIssues(ctx context.Context, client *github.Client, deps ToolDependencies, owner string, repo string, issueNumber int, pagination PaginationParams) (*mcp.CallToolResult, error) {
+ cache, err := deps.GetRepoAccessCache(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get repo access cache: %w", err)
+ }
+ featureFlags := deps.GetFlags(ctx)
+
opts := &github.IssueListOptions{
ListOptions: github.ListOptions{
Page: pagination.Page,
@@ -565,6 +585,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 +653,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 +758,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 +986,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 +1076,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 +1200,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 +1410,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 +1552,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
@@ -1593,6 +1627,104 @@ func (d *mvpDescription) String() string {
return sb.String()
}
+// linkedPullRequest represents a PR linked to an issue by Copilot.
+type linkedPullRequest struct {
+ Number int
+ URL string
+ Title string
+ State string
+ CreatedAt time.Time
+}
+
+// pollConfigKey is a context key for polling configuration.
+type pollConfigKey struct{}
+
+// PollConfig configures the PR polling behavior.
+type PollConfig struct {
+ MaxAttempts int
+ Delay time.Duration
+}
+
+// ContextWithPollConfig returns a context with polling configuration.
+// Use this in tests to reduce or disable polling.
+func ContextWithPollConfig(ctx context.Context, config PollConfig) context.Context {
+ return context.WithValue(ctx, pollConfigKey{}, config)
+}
+
+// getPollConfig returns the polling configuration from context, or defaults.
+func getPollConfig(ctx context.Context) PollConfig {
+ if config, ok := ctx.Value(pollConfigKey{}).(PollConfig); ok {
+ return config
+ }
+ // Default: 9 attempts with 1s delay = 8s max wait
+ // Based on observed latency in remote server: p50 ~5s, p90 ~7s
+ return PollConfig{MaxAttempts: 9, Delay: 1 * time.Second}
+}
+
+// findLinkedCopilotPR searches for a PR created by the copilot-swe-agent bot that references the given issue.
+// It queries the issue's timeline for CrossReferencedEvent items from PRs authored by copilot-swe-agent.
+// The createdAfter parameter filters to only return PRs created after the specified time.
+func findLinkedCopilotPR(ctx context.Context, client *githubv4.Client, owner, repo string, issueNumber int, createdAfter time.Time) (*linkedPullRequest, error) {
+ // Query timeline items looking for CrossReferencedEvent from PRs by copilot-swe-agent
+ var query struct {
+ Repository struct {
+ Issue struct {
+ TimelineItems struct {
+ Nodes []struct {
+ TypeName string `graphql:"__typename"`
+ CrossReferencedEvent struct {
+ Source struct {
+ PullRequest struct {
+ Number int
+ URL string
+ Title string
+ State string
+ CreatedAt githubv4.DateTime
+ Author struct {
+ Login string
+ }
+ } `graphql:"... on PullRequest"`
+ }
+ } `graphql:"... on CrossReferencedEvent"`
+ }
+ } `graphql:"timelineItems(first: 20, itemTypes: [CROSS_REFERENCED_EVENT])"`
+ } `graphql:"issue(number: $number)"`
+ } `graphql:"repository(owner: $owner, name: $name)"`
+ }
+
+ variables := map[string]any{
+ "owner": githubv4.String(owner),
+ "name": githubv4.String(repo),
+ "number": githubv4.Int(issueNumber), //nolint:gosec // Issue numbers are always small positive integers
+ }
+
+ if err := client.Query(ctx, &query, variables); err != nil {
+ return nil, err
+ }
+
+ // Look for a PR from copilot-swe-agent created after the assignment time
+ for _, node := range query.Repository.Issue.TimelineItems.Nodes {
+ if node.TypeName != "CrossReferencedEvent" {
+ continue
+ }
+ pr := node.CrossReferencedEvent.Source.PullRequest
+ if pr.Number > 0 && pr.Author.Login == "copilot-swe-agent" {
+ // Only return PRs created after the assignment time
+ if pr.CreatedAt.Time.After(createdAfter) {
+ return &linkedPullRequest{
+ Number: pr.Number,
+ URL: pr.URL,
+ Title: pr.Title,
+ State: pr.State,
+ CreatedAt: pr.CreatedAt.Time,
+ }, nil
+ }
+ }
+ }
+
+ return nil, nil
+}
+
func AssignCopilotToIssue(t translations.TranslationHelperFunc) inventory.ServerTool {
description := mvpDescription{
summary: "Assign Copilot to a specific issue in a GitHub repository.",
@@ -1626,19 +1758,30 @@ 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",
+ },
+ "custom_instructions": {
+ Type: "string",
+ Description: "Optional custom instructions to guide the agent beyond the issue body. Use this to provide additional context, constraints, or guidance that is not captured in the issue description",
+ },
},
- Required: []string{"owner", "repo", "issueNumber"},
+ Required: []string{"owner", "repo", "issue_number"},
},
},
- func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
+ []scopes.Scope{scopes.Repo},
+ func(ctx context.Context, deps ToolDependencies, request *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"`
+ CustomInstructions string `mapstructure:"custom_instructions"`
}
if err := mapstructure.Decode(args, ¶ms); err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
@@ -1683,7 +1826,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 +1850,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,36 +1872,140 @@ 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
+ }
+
+ // Add custom instructions if provided
+ if params.CustomInstructions != "" {
+ customInstructions := githubv4.String(params.CustomInstructions)
+ agentAssignment.CustomInstructions = &customInstructions
+ }
+
+ // 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 := ghcontext.WithGraphQLFeatures(ctx, "issues_copilot_assignment_api_support")
+
+ // Capture the time before assignment to filter out older PRs during polling
+ assignmentTime := time.Now().UTC()
+
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)
+ }
+
+ // Poll for a linked PR created by Copilot after the assignment
+ pollConfig := getPollConfig(ctx)
+
+ // Get progress token from request for sending progress notifications
+ progressToken := request.Params.GetProgressToken()
+
+ // Send initial progress notification that assignment succeeded and polling is starting
+ if progressToken != nil && request.Session != nil && pollConfig.MaxAttempts > 0 {
+ _ = request.Session.NotifyProgress(ctx, &mcp.ProgressNotificationParams{
+ ProgressToken: progressToken,
+ Progress: 0,
+ Total: float64(pollConfig.MaxAttempts),
+ Message: "Copilot assigned to issue, waiting for PR creation...",
+ })
}
- return utils.NewToolResultText("successfully assigned copilot to issue"), nil, nil
+ var linkedPR *linkedPullRequest
+ for attempt := range pollConfig.MaxAttempts {
+ if attempt > 0 {
+ time.Sleep(pollConfig.Delay)
+ }
+
+ // Send progress notification if progress token is available
+ if progressToken != nil && request.Session != nil {
+ _ = request.Session.NotifyProgress(ctx, &mcp.ProgressNotificationParams{
+ ProgressToken: progressToken,
+ Progress: float64(attempt + 1),
+ Total: float64(pollConfig.MaxAttempts),
+ Message: fmt.Sprintf("Waiting for Copilot to create PR... (attempt %d/%d)", attempt+1, pollConfig.MaxAttempts),
+ })
+ }
+
+ pr, err := findLinkedCopilotPR(ctx, client, params.Owner, params.Repo, int(params.IssueNumber), assignmentTime)
+ if err != nil {
+ // Polling errors are non-fatal, continue to next attempt
+ continue
+ }
+ if pr != nil {
+ linkedPR = pr
+ break
+ }
+ }
+
+ // Build the result
+ result := map[string]any{
+ "message": "successfully assigned copilot to issue",
+ "issue_number": int(updateIssueMutation.UpdateIssue.Issue.Number),
+ "issue_url": string(updateIssueMutation.UpdateIssue.Issue.URL),
+ "owner": params.Owner,
+ "repo": params.Repo,
+ }
+
+ // Add PR info if found during polling
+ if linkedPR != nil {
+ result["pull_request"] = map[string]any{
+ "number": linkedPR.Number,
+ "url": linkedPR.URL,
+ "title": linkedPR.Title,
+ "state": linkedPR.State,
+ }
+ result["message"] = "successfully assigned copilot to issue - pull request created"
+ } else {
+ result["message"] = "successfully assigned copilot to issue - pull request pending"
+ result["note"] = "The pull request may still be in progress. Once created, the PR number can be used to check job status, or check the issue timeline for updates."
+ }
+
+ r, err := json.Marshal(result)
+ if err != nil {
+ return utils.NewToolResultError(fmt.Sprintf("failed to marshal response: %s", err)), nil, nil
+ }
+
+ return utils.NewToolResultText(string(r)), result, nil
})
}
@@ -1767,6 +2014,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"
diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go
index b810cede3..a338efcba 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,16 @@ 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.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "custom_instructions")
+ 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 +2114,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 +2159,7 @@ func TestAssignCopilotToIssue(t *testing.T) {
githubv4mock.NewQueryMatcher(
struct {
Repository struct {
+ ID githubv4.ID
Issue struct {
ID githubv4.ID
Assignees struct {
@@ -2259,6 +2177,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 +2189,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 +2268,7 @@ func TestAssignCopilotToIssue(t *testing.T) {
githubv4mock.NewQueryMatcher(
struct {
Repository struct {
+ ID githubv4.ID
Issue struct {
ID githubv4.ID
Assignees struct {
@@ -2348,6 +2286,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 +2305,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 +2425,7 @@ func TestAssignCopilotToIssue(t *testing.T) {
githubv4mock.NewQueryMatcher(
struct {
Repository struct {
+ ID githubv4.ID
Issue struct {
ID githubv4.ID
Assignees struct {
@@ -2485,6 +2443,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 +2455,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 +2529,226 @@ 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"),
+ },
+ },
+ }),
+ ),
+ ),
+ },
+ {
+ name: "successful assignment with custom_instructions specified",
+ requestArgs: map[string]any{
+ "owner": "owner",
+ "repo": "repo",
+ "issue_number": float64(123),
+ "custom_instructions": "Please ensure all code follows PEP 8 style guidelines and includes comprehensive docstrings",
+ },
+ 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: nil,
+ CustomAgent: ptrGitHubv4String(""),
+ CustomInstructions: ptrGitHubv4String("Please ensure all code follows PEP 8 style guidelines and includes comprehensive docstrings"),
+ 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 {
@@ -2568,8 +2765,12 @@ func TestAssignCopilotToIssue(t *testing.T) {
// Create call request
request := createMCPRequest(tc.requestArgs)
+ // Disable polling in tests to avoid timeouts
+ ctx := ContextWithPollConfig(context.Background(), PollConfig{MaxAttempts: 0})
+ ctx = ContextWithDeps(ctx, deps)
+
// Call handler
- result, err := handler(ContextWithDeps(context.Background(), deps), &request)
+ result, err := handler(ctx, &request)
require.NoError(t, err)
textContent := getTextResult(t, result)
@@ -2581,7 +2782,16 @@ func TestAssignCopilotToIssue(t *testing.T) {
}
require.False(t, result.IsError, fmt.Sprintf("expected there to be no tool error, text was %s", textContent.Text))
- require.Equal(t, textContent.Text, "successfully assigned copilot to issue")
+
+ // Verify the JSON response contains expected fields
+ var response map[string]any
+ err = json.Unmarshal([]byte(textContent.Text), &response)
+ require.NoError(t, err, "response should be valid JSON")
+ assert.Equal(t, float64(123), response["issue_number"])
+ assert.Equal(t, "https://github.com/owner/repo/issues/123", response["issue_url"])
+ assert.Equal(t, "owner", response["owner"])
+ assert.Equal(t, "repo", response["repo"])
+ assert.Contains(t, response["message"], "successfully assigned copilot to issue")
})
}
}
@@ -2631,12 +2841,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 +2857,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 +2872,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 +2888,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 +2903,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 +2918,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 +2933,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 +2948,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 +2960,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 +3080,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 +3094,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 +3115,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 +3129,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 +3143,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 +3157,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 +3171,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 +3182,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 +3298,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 +3313,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 +3328,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 +3343,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 +3358,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 +3373,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 +3388,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 +3400,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 +3506,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 +3522,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 +3538,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 +3551,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 +3566,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 +3582,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 +3598,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 +3614,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 +3630,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 +3646,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 +3659,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 +3757,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 +3768,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 +3779,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/minimal_types.go b/pkg/github/minimal_types.go
index b055efb38..c6a0ea849 100644
--- a/pkg/github/minimal_types.go
+++ b/pkg/github/minimal_types.go
@@ -131,6 +131,7 @@ type MinimalProject struct {
Number *int `json:"number,omitempty"`
ShortDescription *string `json:"short_description,omitempty"`
DeletedBy *MinimalUser `json:"deleted_by,omitempty"`
+ OwnerType string `json:"owner_type,omitempty"`
}
// Helper functions
diff --git a/pkg/github/notifications.go b/pkg/github/notifications.go
index 1e2011fa3..1d695beb3 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 {
@@ -228,7 +231,7 @@ func DismissNotification(t translations.TranslationHelperFunc) inventory.ServerT
}
defer func() { _ = resp.Body.Close() }()
- if resp.StatusCode != http.StatusResetContent && resp.StatusCode != http.StatusOK {
+ if resp.StatusCode != http.StatusResetContent && resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
body, err := io.ReadAll(resp.Body)
if err != nil {
return utils.NewToolResultErrorFromErr("failed to read response body", err), nil, 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/notifications_test.go b/pkg/github/notifications_test.go
index 936a70df4..d2124ae3d 100644
--- a/pkg/github/notifications_test.go
+++ b/pkg/github/notifications_test.go
@@ -472,7 +472,19 @@ func Test_DismissNotification(t *testing.T) {
expectRead: true,
},
{
- name: "mark as done",
+ name: "mark as done with 204 response",
+ mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
+ DeleteNotificationsThreadsByThreadID: mockResponse(t, http.StatusNoContent, nil),
+ }),
+ requestArgs: map[string]interface{}{
+ "threadID": "123",
+ "state": "done",
+ },
+ expectError: false,
+ expectDone: true,
+ },
+ {
+ name: "mark as done with 200 response",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
DeleteNotificationsThreadsByThreadID: mockResponse(t, http.StatusOK, nil),
}),
diff --git a/pkg/github/params.go b/pkg/github/params.go
new file mode 100644
index 000000000..42803a392
--- /dev/null
+++ b/pkg/github/params.go
@@ -0,0 +1,393 @@
+package github
+
+import (
+ "errors"
+ "fmt"
+ "strconv"
+
+ "github.com/google/go-github/v79/github"
+ "github.com/google/jsonschema-go/jsonschema"
+)
+
+// OptionalParamOK is a helper function that can be used to fetch a requested parameter from the request.
+// It returns the value, a boolean indicating if the parameter was present, and an error if the type is wrong.
+func OptionalParamOK[T any, A map[string]any](args A, p string) (value T, ok bool, err error) {
+ // Check if the parameter is present in the request
+ val, exists := args[p]
+ if !exists {
+ // Not present, return zero value, false, no error
+ return
+ }
+
+ // Check if the parameter is of the expected type
+ value, ok = val.(T)
+ if !ok {
+ // Present but wrong type
+ err = fmt.Errorf("parameter %s is not of type %T, is %T", p, value, val)
+ ok = true // Set ok to true because the parameter *was* present, even if wrong type
+ return
+ }
+
+ // Present and correct type
+ ok = true
+ return
+}
+
+// isAcceptedError checks if the error is an accepted error.
+func isAcceptedError(err error) bool {
+ var acceptedError *github.AcceptedError
+ return errors.As(err, &acceptedError)
+}
+
+// RequiredParam is a helper function that can be used to fetch a requested parameter from the request.
+// It does the following checks:
+// 1. Checks if the parameter is present in the request.
+// 2. Checks if the parameter is of the expected type.
+// 3. Checks if the parameter is not empty, i.e: non-zero value
+func RequiredParam[T comparable](args map[string]any, p string) (T, error) {
+ var zero T
+
+ // Check if the parameter is present in the request
+ if _, ok := args[p]; !ok {
+ return zero, fmt.Errorf("missing required parameter: %s", p)
+ }
+
+ // Check if the parameter is of the expected type
+ val, ok := args[p].(T)
+ if !ok {
+ return zero, fmt.Errorf("parameter %s is not of type %T", p, zero)
+ }
+
+ if val == zero {
+ return zero, fmt.Errorf("missing required parameter: %s", p)
+ }
+
+ return val, nil
+}
+
+// RequiredInt is a helper function that can be used to fetch a requested parameter from the request.
+// It does the following checks:
+// 1. Checks if the parameter is present in the request.
+// 2. Checks if the parameter is of the expected type.
+// 3. Checks if the parameter is not empty, i.e: non-zero value
+func RequiredInt(args map[string]any, p string) (int, error) {
+ v, err := RequiredParam[float64](args, p)
+ if err != nil {
+ return 0, err
+ }
+ return int(v), nil
+}
+
+// RequiredBigInt is a helper function that can be used to fetch a requested parameter from the request.
+// It does the following checks:
+// 1. Checks if the parameter is present in the request.
+// 2. Checks if the parameter is of the expected type (float64).
+// 3. Checks if the parameter is not empty, i.e: non-zero value.
+// 4. Validates that the float64 value can be safely converted to int64 without truncation.
+func RequiredBigInt(args map[string]any, p string) (int64, error) {
+ v, err := RequiredParam[float64](args, p)
+ if err != nil {
+ return 0, err
+ }
+
+ result := int64(v)
+ // Check if converting back produces the same value to avoid silent truncation
+ if float64(result) != v {
+ return 0, fmt.Errorf("parameter %s value %f is too large to fit in int64", p, v)
+ }
+ return result, nil
+}
+
+// OptionalParam is a helper function that can be used to fetch a requested parameter from the request.
+// It does the following checks:
+// 1. Checks if the parameter is present in the request, if not, it returns its zero-value
+// 2. If it is present, it checks if the parameter is of the expected type and returns it
+func OptionalParam[T any](args map[string]any, p string) (T, error) {
+ var zero T
+
+ // Check if the parameter is present in the request
+ if _, ok := args[p]; !ok {
+ return zero, nil
+ }
+
+ // Check if the parameter is of the expected type
+ if _, ok := args[p].(T); !ok {
+ return zero, fmt.Errorf("parameter %s is not of type %T, is %T", p, zero, args[p])
+ }
+
+ return args[p].(T), nil
+}
+
+// OptionalIntParam is a helper function that can be used to fetch a requested parameter from the request.
+// It does the following checks:
+// 1. Checks if the parameter is present in the request, if not, it returns its zero-value
+// 2. If it is present, it checks if the parameter is of the expected type and returns it
+func OptionalIntParam(args map[string]any, p string) (int, error) {
+ v, err := OptionalParam[float64](args, p)
+ if err != nil {
+ return 0, err
+ }
+ return int(v), nil
+}
+
+// OptionalIntParamWithDefault is a helper function that can be used to fetch a requested parameter from the request
+// similar to optionalIntParam, but it also takes a default value.
+func OptionalIntParamWithDefault(args map[string]any, p string, d int) (int, error) {
+ v, err := OptionalIntParam(args, p)
+ if err != nil {
+ return 0, err
+ }
+ if v == 0 {
+ return d, nil
+ }
+ return v, nil
+}
+
+// OptionalBoolParamWithDefault is a helper function that can be used to fetch a requested parameter from the request
+// similar to optionalBoolParam, but it also takes a default value.
+func OptionalBoolParamWithDefault(args map[string]any, p string, d bool) (bool, error) {
+ _, ok := args[p]
+ v, err := OptionalParam[bool](args, p)
+ if err != nil {
+ return false, err
+ }
+ if !ok {
+ return d, nil
+ }
+ return v, nil
+}
+
+// OptionalStringArrayParam is a helper function that can be used to fetch a requested parameter from the request.
+// It does the following checks:
+// 1. Checks if the parameter is present in the request, if not, it returns its zero-value
+// 2. If it is present, iterates the elements and checks each is a string
+func OptionalStringArrayParam(args map[string]any, p string) ([]string, error) {
+ // Check if the parameter is present in the request
+ if _, ok := args[p]; !ok {
+ return []string{}, nil
+ }
+
+ switch v := args[p].(type) {
+ case nil:
+ return []string{}, nil
+ case []string:
+ return v, nil
+ case []any:
+ strSlice := make([]string, len(v))
+ for i, v := range v {
+ s, ok := v.(string)
+ if !ok {
+ return []string{}, fmt.Errorf("parameter %s is not of type string, is %T", p, v)
+ }
+ strSlice[i] = s
+ }
+ return strSlice, nil
+ default:
+ return []string{}, fmt.Errorf("parameter %s could not be coerced to []string, is %T", p, args[p])
+ }
+}
+
+func convertStringSliceToBigIntSlice(s []string) ([]int64, error) {
+ int64Slice := make([]int64, len(s))
+ for i, str := range s {
+ val, err := convertStringToBigInt(str, 0)
+ if err != nil {
+ return nil, fmt.Errorf("failed to convert element %d (%s) to int64: %w", i, str, err)
+ }
+ int64Slice[i] = val
+ }
+ return int64Slice, nil
+}
+
+func convertStringToBigInt(s string, def int64) (int64, error) {
+ v, err := strconv.ParseInt(s, 10, 64)
+ if err != nil {
+ return def, fmt.Errorf("failed to convert string %s to int64: %w", s, err)
+ }
+ return v, nil
+}
+
+// OptionalBigIntArrayParam is a helper function that can be used to fetch a requested parameter from the request.
+// It does the following checks:
+// 1. Checks if the parameter is present in the request, if not, it returns an empty slice
+// 2. If it is present, iterates the elements, checks each is a string, and converts them to int64 values
+func OptionalBigIntArrayParam(args map[string]any, p string) ([]int64, error) {
+ // Check if the parameter is present in the request
+ if _, ok := args[p]; !ok {
+ return []int64{}, nil
+ }
+
+ switch v := args[p].(type) {
+ case nil:
+ return []int64{}, nil
+ case []string:
+ return convertStringSliceToBigIntSlice(v)
+ case []any:
+ int64Slice := make([]int64, len(v))
+ for i, v := range v {
+ s, ok := v.(string)
+ if !ok {
+ return []int64{}, fmt.Errorf("parameter %s is not of type string, is %T", p, v)
+ }
+ val, err := convertStringToBigInt(s, 0)
+ if err != nil {
+ return []int64{}, fmt.Errorf("parameter %s: failed to convert element %d (%s) to int64: %w", p, i, s, err)
+ }
+ int64Slice[i] = val
+ }
+ return int64Slice, nil
+ default:
+ return []int64{}, fmt.Errorf("parameter %s could not be coerced to []int64, is %T", p, args[p])
+ }
+}
+
+// WithPagination adds REST API pagination parameters to a tool.
+// https://docs.github.com/en/rest/using-the-rest-api/using-pagination-in-the-rest-api
+func WithPagination(schema *jsonschema.Schema) *jsonschema.Schema {
+ schema.Properties["page"] = &jsonschema.Schema{
+ Type: "number",
+ Description: "Page number for pagination (min 1)",
+ Minimum: jsonschema.Ptr(1.0),
+ }
+
+ schema.Properties["perPage"] = &jsonschema.Schema{
+ Type: "number",
+ Description: "Results per page for pagination (min 1, max 100)",
+ Minimum: jsonschema.Ptr(1.0),
+ Maximum: jsonschema.Ptr(100.0),
+ }
+
+ return schema
+}
+
+// WithUnifiedPagination adds REST API pagination parameters to a tool.
+// GraphQL tools will use this and convert page/perPage to GraphQL cursor parameters internally.
+func WithUnifiedPagination(schema *jsonschema.Schema) *jsonschema.Schema {
+ schema.Properties["page"] = &jsonschema.Schema{
+ Type: "number",
+ Description: "Page number for pagination (min 1)",
+ Minimum: jsonschema.Ptr(1.0),
+ }
+
+ schema.Properties["perPage"] = &jsonschema.Schema{
+ Type: "number",
+ Description: "Results per page for pagination (min 1, max 100)",
+ Minimum: jsonschema.Ptr(1.0),
+ Maximum: jsonschema.Ptr(100.0),
+ }
+
+ schema.Properties["after"] = &jsonschema.Schema{
+ Type: "string",
+ Description: "Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs.",
+ }
+
+ return schema
+}
+
+// WithCursorPagination adds only cursor-based pagination parameters to a tool (no page parameter).
+func WithCursorPagination(schema *jsonschema.Schema) *jsonschema.Schema {
+ schema.Properties["perPage"] = &jsonschema.Schema{
+ Type: "number",
+ Description: "Results per page for pagination (min 1, max 100)",
+ Minimum: jsonschema.Ptr(1.0),
+ Maximum: jsonschema.Ptr(100.0),
+ }
+
+ schema.Properties["after"] = &jsonschema.Schema{
+ Type: "string",
+ Description: "Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs.",
+ }
+
+ return schema
+}
+
+type PaginationParams struct {
+ Page int
+ PerPage int
+ After string
+}
+
+// OptionalPaginationParams returns the "page", "perPage", and "after" parameters from the request,
+// or their default values if not present, "page" default is 1, "perPage" default is 30.
+// In future, we may want to make the default values configurable, or even have this
+// function returned from `withPagination`, where the defaults are provided alongside
+// the min/max values.
+func OptionalPaginationParams(args map[string]any) (PaginationParams, error) {
+ page, err := OptionalIntParamWithDefault(args, "page", 1)
+ if err != nil {
+ return PaginationParams{}, err
+ }
+ perPage, err := OptionalIntParamWithDefault(args, "perPage", 30)
+ if err != nil {
+ return PaginationParams{}, err
+ }
+ after, err := OptionalParam[string](args, "after")
+ if err != nil {
+ return PaginationParams{}, err
+ }
+ return PaginationParams{
+ Page: page,
+ PerPage: perPage,
+ After: after,
+ }, nil
+}
+
+// OptionalCursorPaginationParams returns the "perPage" and "after" parameters from the request,
+// without the "page" parameter, suitable for cursor-based pagination only.
+func OptionalCursorPaginationParams(args map[string]any) (CursorPaginationParams, error) {
+ perPage, err := OptionalIntParamWithDefault(args, "perPage", 30)
+ if err != nil {
+ return CursorPaginationParams{}, err
+ }
+ after, err := OptionalParam[string](args, "after")
+ if err != nil {
+ return CursorPaginationParams{}, err
+ }
+ return CursorPaginationParams{
+ PerPage: perPage,
+ After: after,
+ }, nil
+}
+
+type CursorPaginationParams struct {
+ PerPage int
+ After string
+}
+
+// ToGraphQLParams converts cursor pagination parameters to GraphQL-specific parameters.
+func (p CursorPaginationParams) ToGraphQLParams() (*GraphQLPaginationParams, error) {
+ if p.PerPage > 100 {
+ return nil, fmt.Errorf("perPage value %d exceeds maximum of 100", p.PerPage)
+ }
+ if p.PerPage < 0 {
+ return nil, fmt.Errorf("perPage value %d cannot be negative", p.PerPage)
+ }
+ first := int32(p.PerPage)
+
+ var after *string
+ if p.After != "" {
+ after = &p.After
+ }
+
+ return &GraphQLPaginationParams{
+ First: &first,
+ After: after,
+ }, nil
+}
+
+type GraphQLPaginationParams struct {
+ First *int32
+ After *string
+}
+
+// ToGraphQLParams converts REST API pagination parameters to GraphQL-specific parameters.
+// This converts page/perPage to first parameter for GraphQL queries.
+// If After is provided, it takes precedence over page-based pagination.
+func (p PaginationParams) ToGraphQLParams() (*GraphQLPaginationParams, error) {
+ // Convert to CursorPaginationParams and delegate to avoid duplication
+ cursor := CursorPaginationParams{
+ PerPage: p.PerPage,
+ After: p.After,
+ }
+ return cursor.ToGraphQLParams()
+}
diff --git a/pkg/github/params_test.go b/pkg/github/params_test.go
new file mode 100644
index 000000000..9d7cfe432
--- /dev/null
+++ b/pkg/github/params_test.go
@@ -0,0 +1,503 @@
+package github
+
+import (
+ "fmt"
+ "testing"
+
+ "github.com/google/go-github/v79/github"
+ "github.com/stretchr/testify/assert"
+)
+
+func Test_IsAcceptedError(t *testing.T) {
+ tests := []struct {
+ name string
+ err error
+ expectAccepted bool
+ }{
+ {
+ name: "github AcceptedError",
+ err: &github.AcceptedError{},
+ expectAccepted: true,
+ },
+ {
+ name: "regular error",
+ err: fmt.Errorf("some other error"),
+ expectAccepted: false,
+ },
+ {
+ name: "nil error",
+ err: nil,
+ expectAccepted: false,
+ },
+ {
+ name: "wrapped AcceptedError",
+ err: fmt.Errorf("wrapped: %w", &github.AcceptedError{}),
+ expectAccepted: true,
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ result := isAcceptedError(tc.err)
+ assert.Equal(t, tc.expectAccepted, result)
+ })
+ }
+}
+
+func Test_RequiredStringParam(t *testing.T) {
+ tests := []struct {
+ name string
+ params map[string]interface{}
+ paramName string
+ expected string
+ expectError bool
+ }{
+ {
+ name: "valid string parameter",
+ params: map[string]interface{}{"name": "test-value"},
+ paramName: "name",
+ expected: "test-value",
+ expectError: false,
+ },
+ {
+ name: "missing parameter",
+ params: map[string]interface{}{},
+ paramName: "name",
+ expected: "",
+ expectError: true,
+ },
+ {
+ name: "empty string parameter",
+ params: map[string]interface{}{"name": ""},
+ paramName: "name",
+ expected: "",
+ expectError: true,
+ },
+ {
+ name: "wrong type parameter",
+ params: map[string]interface{}{"name": 123},
+ paramName: "name",
+ expected: "",
+ expectError: true,
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ result, err := RequiredParam[string](tc.params, tc.paramName)
+
+ if tc.expectError {
+ assert.Error(t, err)
+ } else {
+ assert.NoError(t, err)
+ assert.Equal(t, tc.expected, result)
+ }
+ })
+ }
+}
+
+func Test_OptionalStringParam(t *testing.T) {
+ tests := []struct {
+ name string
+ params map[string]interface{}
+ paramName string
+ expected string
+ expectError bool
+ }{
+ {
+ name: "valid string parameter",
+ params: map[string]interface{}{"name": "test-value"},
+ paramName: "name",
+ expected: "test-value",
+ expectError: false,
+ },
+ {
+ name: "missing parameter",
+ params: map[string]interface{}{},
+ paramName: "name",
+ expected: "",
+ expectError: false,
+ },
+ {
+ name: "empty string parameter",
+ params: map[string]interface{}{"name": ""},
+ paramName: "name",
+ expected: "",
+ expectError: false,
+ },
+ {
+ name: "wrong type parameter",
+ params: map[string]interface{}{"name": 123},
+ paramName: "name",
+ expected: "",
+ expectError: true,
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ result, err := OptionalParam[string](tc.params, tc.paramName)
+
+ if tc.expectError {
+ assert.Error(t, err)
+ } else {
+ assert.NoError(t, err)
+ assert.Equal(t, tc.expected, result)
+ }
+ })
+ }
+}
+
+func Test_RequiredInt(t *testing.T) {
+ tests := []struct {
+ name string
+ params map[string]interface{}
+ paramName string
+ expected int
+ expectError bool
+ }{
+ {
+ name: "valid number parameter",
+ params: map[string]interface{}{"count": float64(42)},
+ paramName: "count",
+ expected: 42,
+ expectError: false,
+ },
+ {
+ name: "missing parameter",
+ params: map[string]interface{}{},
+ paramName: "count",
+ expected: 0,
+ expectError: true,
+ },
+ {
+ name: "wrong type parameter",
+ params: map[string]interface{}{"count": "not-a-number"},
+ paramName: "count",
+ expected: 0,
+ expectError: true,
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ result, err := RequiredInt(tc.params, tc.paramName)
+
+ if tc.expectError {
+ assert.Error(t, err)
+ } else {
+ assert.NoError(t, err)
+ assert.Equal(t, tc.expected, result)
+ }
+ })
+ }
+}
+func Test_OptionalIntParam(t *testing.T) {
+ tests := []struct {
+ name string
+ params map[string]interface{}
+ paramName string
+ expected int
+ expectError bool
+ }{
+ {
+ name: "valid number parameter",
+ params: map[string]interface{}{"count": float64(42)},
+ paramName: "count",
+ expected: 42,
+ expectError: false,
+ },
+ {
+ name: "missing parameter",
+ params: map[string]interface{}{},
+ paramName: "count",
+ expected: 0,
+ expectError: false,
+ },
+ {
+ name: "zero value",
+ params: map[string]interface{}{"count": float64(0)},
+ paramName: "count",
+ expected: 0,
+ expectError: false,
+ },
+ {
+ name: "wrong type parameter",
+ params: map[string]interface{}{"count": "not-a-number"},
+ paramName: "count",
+ expected: 0,
+ expectError: true,
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ result, err := OptionalIntParam(tc.params, tc.paramName)
+
+ if tc.expectError {
+ assert.Error(t, err)
+ } else {
+ assert.NoError(t, err)
+ assert.Equal(t, tc.expected, result)
+ }
+ })
+ }
+}
+
+func Test_OptionalNumberParamWithDefault(t *testing.T) {
+ tests := []struct {
+ name string
+ params map[string]interface{}
+ paramName string
+ defaultVal int
+ expected int
+ expectError bool
+ }{
+ {
+ name: "valid number parameter",
+ params: map[string]interface{}{"count": float64(42)},
+ paramName: "count",
+ defaultVal: 10,
+ expected: 42,
+ expectError: false,
+ },
+ {
+ name: "missing parameter",
+ params: map[string]interface{}{},
+ paramName: "count",
+ defaultVal: 10,
+ expected: 10,
+ expectError: false,
+ },
+ {
+ name: "zero value",
+ params: map[string]interface{}{"count": float64(0)},
+ paramName: "count",
+ defaultVal: 10,
+ expected: 10,
+ expectError: false,
+ },
+ {
+ name: "wrong type parameter",
+ params: map[string]interface{}{"count": "not-a-number"},
+ paramName: "count",
+ defaultVal: 10,
+ expected: 0,
+ expectError: true,
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ result, err := OptionalIntParamWithDefault(tc.params, tc.paramName, tc.defaultVal)
+
+ if tc.expectError {
+ assert.Error(t, err)
+ } else {
+ assert.NoError(t, err)
+ assert.Equal(t, tc.expected, result)
+ }
+ })
+ }
+}
+
+func Test_OptionalBooleanParam(t *testing.T) {
+ tests := []struct {
+ name string
+ params map[string]interface{}
+ paramName string
+ expected bool
+ expectError bool
+ }{
+ {
+ name: "true value",
+ params: map[string]interface{}{"flag": true},
+ paramName: "flag",
+ expected: true,
+ expectError: false,
+ },
+ {
+ name: "false value",
+ params: map[string]interface{}{"flag": false},
+ paramName: "flag",
+ expected: false,
+ expectError: false,
+ },
+ {
+ name: "missing parameter",
+ params: map[string]interface{}{},
+ paramName: "flag",
+ expected: false,
+ expectError: false,
+ },
+ {
+ name: "wrong type parameter",
+ params: map[string]interface{}{"flag": "not-a-boolean"},
+ paramName: "flag",
+ expected: false,
+ expectError: true,
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ result, err := OptionalParam[bool](tc.params, tc.paramName)
+
+ if tc.expectError {
+ assert.Error(t, err)
+ } else {
+ assert.NoError(t, err)
+ assert.Equal(t, tc.expected, result)
+ }
+ })
+ }
+}
+
+func TestOptionalStringArrayParam(t *testing.T) {
+ tests := []struct {
+ name string
+ params map[string]interface{}
+ paramName string
+ expected []string
+ expectError bool
+ }{
+ {
+ name: "parameter not in request",
+ params: map[string]any{},
+ paramName: "flag",
+ expected: []string{},
+ expectError: false,
+ },
+ {
+ name: "valid any array parameter",
+ params: map[string]any{
+ "flag": []any{"v1", "v2"},
+ },
+ paramName: "flag",
+ expected: []string{"v1", "v2"},
+ expectError: false,
+ },
+ {
+ name: "valid string array parameter",
+ params: map[string]any{
+ "flag": []string{"v1", "v2"},
+ },
+ paramName: "flag",
+ expected: []string{"v1", "v2"},
+ expectError: false,
+ },
+ {
+ name: "wrong type parameter",
+ params: map[string]any{
+ "flag": 1,
+ },
+ paramName: "flag",
+ expected: []string{},
+ expectError: true,
+ },
+ {
+ name: "wrong slice type parameter",
+ params: map[string]any{
+ "flag": []any{"foo", 2},
+ },
+ paramName: "flag",
+ expected: []string{},
+ expectError: true,
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ result, err := OptionalStringArrayParam(tc.params, tc.paramName)
+
+ if tc.expectError {
+ assert.Error(t, err)
+ } else {
+ assert.NoError(t, err)
+ assert.Equal(t, tc.expected, result)
+ }
+ })
+ }
+}
+
+func TestOptionalPaginationParams(t *testing.T) {
+ tests := []struct {
+ name string
+ params map[string]any
+ expected PaginationParams
+ expectError bool
+ }{
+ {
+ name: "no pagination parameters, default values",
+ params: map[string]any{},
+ expected: PaginationParams{
+ Page: 1,
+ PerPage: 30,
+ },
+ expectError: false,
+ },
+ {
+ name: "page parameter, default perPage",
+ params: map[string]any{
+ "page": float64(2),
+ },
+ expected: PaginationParams{
+ Page: 2,
+ PerPage: 30,
+ },
+ expectError: false,
+ },
+ {
+ name: "perPage parameter, default page",
+ params: map[string]any{
+ "perPage": float64(50),
+ },
+ expected: PaginationParams{
+ Page: 1,
+ PerPage: 50,
+ },
+ expectError: false,
+ },
+ {
+ name: "page and perPage parameters",
+ params: map[string]any{
+ "page": float64(2),
+ "perPage": float64(50),
+ },
+ expected: PaginationParams{
+ Page: 2,
+ PerPage: 50,
+ },
+ expectError: false,
+ },
+ {
+ name: "invalid page parameter",
+ params: map[string]any{
+ "page": "not-a-number",
+ },
+ expected: PaginationParams{},
+ expectError: true,
+ },
+ {
+ name: "invalid perPage parameter",
+ params: map[string]any{
+ "perPage": "not-a-number",
+ },
+ expected: PaginationParams{},
+ expectError: true,
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ result, err := OptionalPaginationParams(tc.params)
+
+ if tc.expectError {
+ assert.Error(t, err)
+ } else {
+ assert.NoError(t, err)
+ assert.Equal(t, tc.expected, result)
+ }
+ })
+ }
+}
diff --git a/pkg/github/projects.go b/pkg/github/projects.go
index 18c1f778b..4fed6364f 100644
--- a/pkg/github/projects.go
+++ b/pkg/github/projects.go
@@ -10,11 +10,13 @@ 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"
"github.com/google/jsonschema-go/jsonschema"
"github.com/modelcontextprotocol/go-sdk/mcp"
+ "github.com/shurcooL/githubv4"
)
const (
@@ -25,8 +27,25 @@ const (
MaxProjectsPerPage = 50
)
+// FeatureFlagHoldbackConsolidatedProjects is the feature flag that, when enabled, reverts to
+// individual project tools instead of the consolidated project tools.
+const FeatureFlagHoldbackConsolidatedProjects = "mcp_holdback_consolidated_projects"
+
+// Method constants for consolidated project tools
+const (
+ projectsMethodListProjects = "list_projects"
+ 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 +86,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 +160,12 @@ func ListProjects(t translations.TranslationHelperFunc) inventory.ServerTool {
return utils.NewToolResultText(string(r)), nil, nil
},
)
+ tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedProjects
+ return tool
}
func GetProject(t translations.TranslationHelperFunc) inventory.ServerTool {
- return NewTool(
+ tool := NewTool(
ToolsetMetadataProjects,
mcp.Tool{
Name: "get_project",
@@ -172,6 +194,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 +251,12 @@ func GetProject(t translations.TranslationHelperFunc) inventory.ServerTool {
return utils.NewToolResultText(string(r)), nil, nil
},
)
+ tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedProjects
+ return tool
}
func ListProjectFields(t translations.TranslationHelperFunc) inventory.ServerTool {
- return NewTool(
+ tool := NewTool(
ToolsetMetadataProjects,
mcp.Tool{
Name: "list_project_fields",
@@ -272,6 +297,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 +360,12 @@ func ListProjectFields(t translations.TranslationHelperFunc) inventory.ServerToo
return utils.NewToolResultText(string(r)), nil, nil
},
)
+ tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedProjects
+ return tool
}
func GetProjectField(t translations.TranslationHelperFunc) inventory.ServerTool {
- return NewTool(
+ tool := NewTool(
ToolsetMetadataProjects,
mcp.Tool{
Name: "get_project_field",
@@ -370,6 +398,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 +455,12 @@ func GetProjectField(t translations.TranslationHelperFunc) inventory.ServerTool
return utils.NewToolResultText(string(r)), nil, nil
},
)
+ tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedProjects
+ return tool
}
func ListProjectItems(t translations.TranslationHelperFunc) inventory.ServerTool {
- return NewTool(
+ tool := NewTool(
ToolsetMetadataProjects,
mcp.Tool{
Name: "list_project_items",
@@ -481,6 +512,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 +594,12 @@ func ListProjectItems(t translations.TranslationHelperFunc) inventory.ServerTool
return utils.NewToolResultText(string(r)), nil, nil
},
)
+ tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedProjects
+ return tool
}
func GetProjectItem(t translations.TranslationHelperFunc) inventory.ServerTool {
- return NewTool(
+ tool := NewTool(
ToolsetMetadataProjects,
mcp.Tool{
Name: "get_project_item",
@@ -605,6 +639,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 +703,12 @@ func GetProjectItem(t translations.TranslationHelperFunc) inventory.ServerTool {
return utils.NewToolResultText(string(r)), nil, nil
},
)
+ tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedProjects
+ return tool
}
func AddProjectItem(t translations.TranslationHelperFunc) inventory.ServerTool {
- return NewTool(
+ tool := NewTool(
ToolsetMetadataProjects,
mcp.Tool{
Name: "add_project_item",
@@ -709,6 +746,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 +817,12 @@ func AddProjectItem(t translations.TranslationHelperFunc) inventory.ServerTool {
return utils.NewToolResultText(string(r)), nil, nil
},
)
+ tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedProjects
+ return tool
}
func UpdateProjectItem(t translations.TranslationHelperFunc) inventory.ServerTool {
- return NewTool(
+ tool := NewTool(
ToolsetMetadataProjects,
mcp.Tool{
Name: "update_project_item",
@@ -819,6 +859,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,17 +932,20 @@ func UpdateProjectItem(t translations.TranslationHelperFunc) inventory.ServerToo
return utils.NewToolResultText(string(r)), nil, nil
},
)
+ tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedProjects
+ return tool
}
func DeleteProjectItem(t translations.TranslationHelperFunc) inventory.ServerTool {
- return NewTool(
+ tool := NewTool(
ToolsetMetadataProjects,
mcp.Tool{
Name: "delete_project_item",
Description: t("TOOL_DELETE_PROJECT_ITEM_DESCRIPTION", "Delete a specific Project item for a user or org"),
Annotations: &mcp.ToolAnnotations{
- Title: t("TOOL_DELETE_PROJECT_ITEM_USER_TITLE", "Delete project item"),
- ReadOnlyHint: false,
+ Title: t("TOOL_DELETE_PROJECT_ITEM_USER_TITLE", "Delete project item"),
+ ReadOnlyHint: false,
+ DestructiveHint: jsonschema.Ptr(true),
},
InputSchema: &jsonschema.Schema{
Type: "object",
@@ -927,6 +971,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")
@@ -976,6 +1021,935 @@ func DeleteProjectItem(t translations.TranslationHelperFunc) inventory.ServerToo
return utils.NewToolResultText("project item successfully deleted"), nil, nil
},
)
+ tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedProjects
+ 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 (user or org). If not provided, will automatically try both.",
+ Enum: []any{"user", "org"},
+ },
+ "owner": {
+ Type: "string",
+ Description: "The owner (user or organization login). 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"},
+ },
+ },
+ []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 := OptionalParam[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:
+ // Detect owner type if not provided and project_number is available
+ if ownerType == "" {
+ projectNumber, err := RequiredInt(args, "project_number")
+ if err != nil {
+ return utils.NewToolResultError(err.Error()), nil, nil
+ }
+ ownerType, err = detectOwnerType(ctx, client, owner, projectNumber)
+ if err != nil {
+ return utils.NewToolResultError(err.Error()), nil, nil
+ }
+ }
+ return listProjectFields(ctx, client, args, owner, ownerType)
+ case projectsMethodListProjectItems:
+ // Detect owner type if not provided and project_number is available
+ if ownerType == "" {
+ projectNumber, err := RequiredInt(args, "project_number")
+ if err != nil {
+ return utils.NewToolResultError(err.Error()), nil, nil
+ }
+ ownerType, err = detectOwnerType(ctx, client, owner, projectNumber)
+ if err != nil {
+ return utils.NewToolResultError(err.Error()), nil, nil
+ }
+ }
+ return listProjectItems(ctx, client, args, owner, ownerType)
+ default:
+ return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil
+ }
+ },
+ )
+ tool.FeatureFlagDisable = FeatureFlagHoldbackConsolidatedProjects
+ 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 (user or org). If not provided, will be automatically detected.",
+ Enum: []any{"user", "org"},
+ },
+ "owner": {
+ Type: "string",
+ Description: "The owner (user or organization login). 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", "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 := OptionalParam[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
+ }
+
+ // Detect owner type if not provided
+ if ownerType == "" {
+ ownerType, err = detectOwnerType(ctx, client, owner, projectNumber)
+ if err != nil {
+ return utils.NewToolResultError(err.Error()), nil, nil
+ }
+ }
+
+ switch method {
+ case projectsMethodGetProject:
+ return getProject(ctx, client, owner, ownerType, projectNumber)
+ 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.FeatureFlagDisable = FeatureFlagHoldbackConsolidatedProjects
+ 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 (user or org). If not provided, will be automatically detected.",
+ Enum: []any{"user", "org"},
+ },
+ "owner": {
+ Type: "string",
+ Description: "The project owner (user or organization login). 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.",
+ },
+ "item_type": {
+ Type: "string",
+ Description: "The item's type, either issue or pull_request. Required for 'add_project_item' method.",
+ Enum: []any{"issue", "pull_request"},
+ },
+ "item_owner": {
+ Type: "string",
+ Description: "The owner (user or organization) of the repository containing the issue or pull request. Required for 'add_project_item' method.",
+ },
+ "item_repo": {
+ Type: "string",
+ Description: "The name of the repository containing the issue or pull request. Required for 'add_project_item' method.",
+ },
+ "issue_number": {
+ Type: "number",
+ Description: "The issue number (use when item_type is 'issue' for 'add_project_item' method). Provide either issue_number or pull_request_number.",
+ },
+ "pull_request_number": {
+ Type: "number",
+ Description: "The pull request number (use when item_type is 'pull_request' for 'add_project_item' method). Provide either issue_number or pull_request_number.",
+ },
+ "updated_field": {
+ Type: "object",
+ Description: "Object consisting of the ID of the project field to update and the new value for the field. To clear the field, set value to null. Example: {\"id\": 123456, \"value\": \"New Value\"}. Required for 'update_project_item' method.",
+ },
+ },
+ Required: []string{"method", "owner", "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 := OptionalParam[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
+ }
+
+ gqlClient, err := deps.GetGQLClient(ctx)
+ if err != nil {
+ return utils.NewToolResultError(err.Error()), nil, nil
+ }
+
+ // Detect owner type if not provided
+ if ownerType == "" {
+ ownerType, err = detectOwnerType(ctx, client, owner, projectNumber)
+ if err != nil {
+ return utils.NewToolResultError(err.Error()), nil, nil
+ }
+ }
+
+ switch method {
+ case projectsMethodAddProjectItem:
+ itemType, err := RequiredParam[string](args, "item_type")
+ if err != nil {
+ return utils.NewToolResultError(err.Error()), nil, nil
+ }
+ itemOwner, err := RequiredParam[string](args, "item_owner")
+ if err != nil {
+ return utils.NewToolResultError(err.Error()), nil, nil
+ }
+ itemRepo, err := RequiredParam[string](args, "item_repo")
+ if err != nil {
+ return utils.NewToolResultError(err.Error()), nil, nil
+ }
+
+ var itemNumber int
+ switch itemType {
+ case "issue":
+ itemNumber, err = RequiredInt(args, "issue_number")
+ if err != nil {
+ return utils.NewToolResultError("issue_number is required when item_type is 'issue'"), nil, nil
+ }
+ case "pull_request":
+ itemNumber, err = RequiredInt(args, "pull_request_number")
+ if err != nil {
+ return utils.NewToolResultError("pull_request_number is required when item_type is 'pull_request'"), nil, nil
+ }
+ default:
+ return utils.NewToolResultError("item_type must be either 'issue' or 'pull_request'"), nil, nil
+ }
+
+ return addProjectItem(ctx, gqlClient, owner, ownerType, projectNumber, itemOwner, itemRepo, itemNumber, itemType)
+ case projectsMethodUpdateProjectItem:
+ itemID, err := RequiredBigInt(args, "item_id")
+ if err != nil {
+ 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.FeatureFlagDisable = FeatureFlagHoldbackConsolidatedProjects
+ 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 owner_type not provided, fetch from both user and org
+ switch ownerType {
+ case "":
+ return listProjectsFromBothOwnerTypes(ctx, client, owner, opts)
+ case "org":
+ projects, resp, err = client.Projects.ListOrganizationProjects(ctx, owner, opts)
+ if err != nil {
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ "failed to list projects",
+ resp,
+ err,
+ ), nil, nil
+ }
+ default:
+ projects, resp, err = client.Projects.ListUserProjects(ctx, owner, opts)
+ if err != nil {
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ "failed to list projects",
+ resp,
+ err,
+ ), nil, nil
+ }
+ }
+
+ // For specified owner_type, process normally
+ if ownerType != "" {
+ defer func() { _ = resp.Body.Close() }()
+
+ for _, project := range projects {
+ mp := convertToMinimalProject(project)
+ mp.OwnerType = ownerType
+ minimalProjects = append(minimalProjects, *mp)
+ }
+
+ response := map[string]any{
+ "projects": minimalProjects,
+ "pageInfo": buildPageInfo(resp),
+ }
+
+ r, err := json.Marshal(response)
+ if err != nil {
+ return nil, nil, fmt.Errorf("failed to marshal response: %w", err)
+ }
+
+ return utils.NewToolResultText(string(r)), nil, nil
+ }
+
+ return nil, nil, fmt.Errorf("unexpected state in listProjects")
+}
+
+// listProjectsFromBothOwnerTypes fetches projects from both user and org endpoints
+// when owner_type is not specified, combining the results with owner_type labels.
+func listProjectsFromBothOwnerTypes(ctx context.Context, client *github.Client, owner string, opts *github.ListProjectsOptions) (*mcp.CallToolResult, any, error) {
+ var minimalProjects []MinimalProject
+ var resp *github.Response
+
+ // Fetch user projects
+ userProjects, userResp, userErr := client.Projects.ListUserProjects(ctx, owner, opts)
+ if userErr == nil && userResp.StatusCode == http.StatusOK {
+ for _, project := range userProjects {
+ mp := convertToMinimalProject(project)
+ mp.OwnerType = "user"
+ minimalProjects = append(minimalProjects, *mp)
+ }
+ _ = userResp.Body.Close()
+ }
+
+ // Fetch org projects
+ orgProjects, orgResp, orgErr := client.Projects.ListOrganizationProjects(ctx, owner, opts)
+ if orgErr == nil && orgResp.StatusCode == http.StatusOK {
+ for _, project := range orgProjects {
+ mp := convertToMinimalProject(project)
+ mp.OwnerType = "org"
+ minimalProjects = append(minimalProjects, *mp)
+ }
+ resp = orgResp // Use org response for pagination info
+ } else if userResp != nil {
+ resp = userResp // Fallback to user response
+ }
+
+ // If both failed, return error
+ if (userErr != nil || userResp == nil || userResp.StatusCode != http.StatusOK) &&
+ (orgErr != nil || orgResp == nil || orgResp.StatusCode != http.StatusOK) {
+ return utils.NewToolResultError(fmt.Sprintf("failed to list projects for owner '%s': not found as user or organization", owner)), nil, nil
+ }
+
+ response := map[string]any{
+ "projects": minimalProjects,
+ "note": "Results include both user and org projects. Each project includes 'owner_type' field. Pagination is limited when owner_type is not specified - specify 'owner_type' for full pagination support.",
+ }
+ if resp != nil {
+ response["pageInfo"] = buildPageInfo(resp)
+ defer func() { _ = resp.Body.Close() }()
+ }
+
+ r, err := json.Marshal(response)
+ if err != nil {
+ return nil, nil, fmt.Errorf("failed to marshal response: %w", err)
+ }
+ return utils.NewToolResultText(string(r)), nil, nil
+}
+
+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 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
+}
+
+// addProjectItem adds an item to a project by resolving the issue/PR number to a node ID
+func addProjectItem(ctx context.Context, gqlClient *githubv4.Client, owner, ownerType string, projectNumber int, itemOwner, itemRepo string, itemNumber int, itemType string) (*mcp.CallToolResult, any, error) {
+ if itemType != "issue" && itemType != "pull_request" {
+ return utils.NewToolResultError("item_type must be either 'issue' or 'pull_request'"), nil, nil
+ }
+
+ // Resolve the item number to a node ID
+ var nodeID githubv4.ID
+ var err error
+ if itemType == "issue" {
+ nodeID, err = resolveIssueNodeID(ctx, gqlClient, itemOwner, itemRepo, itemNumber)
+ } else {
+ nodeID, err = resolvePullRequestNodeID(ctx, gqlClient, itemOwner, itemRepo, itemNumber)
+ }
+ if err != nil {
+ return utils.NewToolResultError(fmt.Sprintf("failed to resolve %s: %v", itemType, err)), nil, nil
+ }
+
+ // Use GraphQL to add the item to the project
+ var mutation struct {
+ AddProjectV2ItemByID struct {
+ Item struct {
+ ID githubv4.ID
+ }
+ } `graphql:"addProjectV2ItemById(input: $input)"`
+ }
+
+ // First, get the project ID
+ var projectIDQuery struct {
+ User struct {
+ ProjectV2 struct {
+ ID githubv4.ID
+ } `graphql:"projectV2(number: $projectNumber)"`
+ } `graphql:"user(login: $owner)"`
+ }
+ var projectIDQueryOrg struct {
+ Organization struct {
+ ProjectV2 struct {
+ ID githubv4.ID
+ } `graphql:"projectV2(number: $projectNumber)"`
+ } `graphql:"organization(login: $owner)"`
+ }
+
+ var projectID githubv4.ID
+ if ownerType == "org" {
+ err = gqlClient.Query(ctx, &projectIDQueryOrg, map[string]any{
+ "owner": githubv4.String(owner),
+ "projectNumber": githubv4.Int(int32(projectNumber)), //nolint:gosec // Project numbers are small integers
+ })
+ if err != nil {
+ return utils.NewToolResultError(fmt.Sprintf("failed to get project ID: %v", err)), nil, nil
+ }
+ projectID = projectIDQueryOrg.Organization.ProjectV2.ID
+ } else {
+ err = gqlClient.Query(ctx, &projectIDQuery, map[string]any{
+ "owner": githubv4.String(owner),
+ "projectNumber": githubv4.Int(int32(projectNumber)), //nolint:gosec // Project numbers are small integers
+ })
+ if err != nil {
+ return utils.NewToolResultError(fmt.Sprintf("failed to get project ID: %v", err)), nil, nil
+ }
+ projectID = projectIDQuery.User.ProjectV2.ID
+ }
+
+ // Add the item to the project
+ input := githubv4.AddProjectV2ItemByIdInput{
+ ProjectID: projectID,
+ ContentID: nodeID,
+ }
+
+ err = gqlClient.Mutate(ctx, &mutation, input, nil)
+ if err != nil {
+ return utils.NewToolResultError(fmt.Sprintf(ProjectAddFailedError+": %v", err)), nil, nil
+ }
+
+ result := map[string]any{
+ "id": mutation.AddProjectV2ItemByID.Item.ID,
+ "message": fmt.Sprintf("Successfully added %s %s/%s#%d to project %s/%d", itemType, itemOwner, itemRepo, itemNumber, owner, projectNumber),
+ }
+
+ r, err := json.Marshal(result)
+ if err != nil {
+ return nil, nil, fmt.Errorf("failed to marshal response: %w", err)
+ }
+
+ return utils.NewToolResultText(string(r)), nil, nil
}
type pageInfo struct {
@@ -1089,3 +2063,77 @@ func extractPaginationOptionsFromArgs(args map[string]any) (github.ListProjectsP
return opts, nil
}
+
+// resolveIssueNodeID resolves an issue number to its GraphQL node ID
+func resolveIssueNodeID(ctx context.Context, gqlClient *githubv4.Client, owner, repo string, issueNumber int) (githubv4.ID, error) {
+ var query struct {
+ Repository struct {
+ Issue struct {
+ ID githubv4.ID
+ } `graphql:"issue(number: $issueNumber)"`
+ } `graphql:"repository(owner: $owner, name: $repo)"`
+ }
+
+ variables := map[string]any{
+ "owner": githubv4.String(owner),
+ "repo": githubv4.String(repo),
+ "issueNumber": githubv4.Int(int32(issueNumber)), //nolint:gosec // Issue numbers are small integers
+ }
+
+ err := gqlClient.Query(ctx, &query, variables)
+ if err != nil {
+ return "", fmt.Errorf("failed to resolve issue %s/%s#%d: %w", owner, repo, issueNumber, err)
+ }
+
+ return query.Repository.Issue.ID, nil
+}
+
+// resolvePullRequestNodeID resolves a pull request number to its GraphQL node ID
+func resolvePullRequestNodeID(ctx context.Context, gqlClient *githubv4.Client, owner, repo string, prNumber int) (githubv4.ID, error) {
+ var query struct {
+ Repository struct {
+ PullRequest struct {
+ ID githubv4.ID
+ } `graphql:"pullRequest(number: $prNumber)"`
+ } `graphql:"repository(owner: $owner, name: $repo)"`
+ }
+
+ variables := map[string]any{
+ "owner": githubv4.String(owner),
+ "repo": githubv4.String(repo),
+ "prNumber": githubv4.Int(int32(prNumber)), //nolint:gosec // PR numbers are small integers
+ }
+
+ err := gqlClient.Query(ctx, &query, variables)
+ if err != nil {
+ return "", fmt.Errorf("failed to resolve pull request %s/%s#%d: %w", owner, repo, prNumber, err)
+ }
+
+ return query.Repository.PullRequest.ID, nil
+}
+
+// detectOwnerType attempts to detect the owner type by trying both user and org
+// Returns the detected type ("user" or "org") and any error encountered
+func detectOwnerType(ctx context.Context, client *github.Client, owner string, projectNumber int) (string, error) {
+ // Try user first (more common for personal projects)
+ _, resp, err := client.Projects.GetUserProject(ctx, owner, projectNumber)
+ if err == nil && resp.StatusCode == http.StatusOK {
+ _ = resp.Body.Close()
+ return "user", nil
+ }
+ if resp != nil {
+ _ = resp.Body.Close()
+ }
+
+ // If not found (404) or other error, try org
+ _, resp, err = client.Projects.GetOrganizationProject(ctx, owner, projectNumber)
+ if err == nil && resp.StatusCode == http.StatusOK {
+ _ = resp.Body.Close()
+ return "org", nil
+ }
+ if resp != nil {
+ _ = resp.Body.Close()
+ }
+
+ return "", fmt.Errorf("could not determine owner type for %s with project %d: owner is neither a user nor an org with this project", owner, projectNumber)
+}
diff --git a/pkg/github/projects_test.go b/pkg/github/projects_test.go
index e443b9ecd..24163ef90 100644
--- a/pkg/github/projects_test.go
+++ b/pkg/github/projects_test.go
@@ -3,15 +3,15 @@ package github
import (
"context"
"encoding/json"
- "io"
"net/http"
"testing"
+ "github.com/github/github-mcp-server/internal/githubv4mock"
"github.com/github/github-mcp-server/internal/toolsnaps"
"github.com/github/github-mcp-server/pkg/translations"
gh "github.com/google/go-github/v79/github"
"github.com/google/jsonschema-go/jsonschema"
- "github.com/migueleliasweb/go-github-mock/src/mock"
+ "github.com/shurcooL/githubv4"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -45,15 +45,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 +57,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 +69,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 +86,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 +98,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 +106,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 +180,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 +192,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 +204,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 +217,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 +226,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 +235,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 +310,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 +322,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 +337,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 +350,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 +359,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 +368,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 +448,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 +461,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 +474,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 +488,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 +498,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 +508,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 +518,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 +610,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 +622,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 +638,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 +650,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 +667,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 +680,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 +689,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 +698,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 +789,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 +802,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 +818,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 +831,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 +845,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 +855,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 +865,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 +875,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 +979,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 +998,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 +1017,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 +1032,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 +1043,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 +1054,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 +1065,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 +1076,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 +1176,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 +1195,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 +1214,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 +1232,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 +1246,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 +1260,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 +1274,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 +1288,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 +1299,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 +1311,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 +1323,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 +1411,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 +1426,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 +1441,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 +1455,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 +1465,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 +1475,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 +1485,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 +1531,802 @@ 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"})
+}
+
+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", "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, "item_owner")
+ assert.Contains(t, inputSchema.Properties, "item_repo")
+ assert.Contains(t, inputSchema.Properties, "issue_number")
+ assert.Contains(t, inputSchema.Properties, "pull_request_number")
+ assert.Contains(t, inputSchema.Properties, "updated_field")
+ assert.ElementsMatch(t, inputSchema.Required, []string{"method", "owner", "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)
+
+ t.Run("success organization with issue", func(t *testing.T) {
+ mockedClient := githubv4mock.NewMockedHTTPClient(
+ // Mock resolveIssueNodeID query
+ githubv4mock.NewQueryMatcher(
+ struct {
+ Repository struct {
+ Issue struct {
+ ID githubv4.ID
+ } `graphql:"issue(number: $issueNumber)"`
+ } `graphql:"repository(owner: $owner, name: $repo)"`
+ }{},
+ map[string]any{
+ "owner": githubv4.String("item-owner"),
+ "repo": githubv4.String("item-repo"),
+ "issueNumber": githubv4.Int(123),
+ },
+ githubv4mock.DataResponse(map[string]any{
+ "repository": map[string]any{
+ "issue": map[string]any{
+ "id": "I_issue123",
+ },
+ },
+ }),
+ ),
+ // Mock project ID query for org
+ githubv4mock.NewQueryMatcher(
+ struct {
+ Organization struct {
+ ProjectV2 struct {
+ ID githubv4.ID
+ } `graphql:"projectV2(number: $projectNumber)"`
+ } `graphql:"organization(login: $owner)"`
+ }{},
+ map[string]any{
+ "owner": githubv4.String("octo-org"),
+ "projectNumber": githubv4.Int(1),
+ },
+ githubv4mock.DataResponse(map[string]any{
+ "organization": map[string]any{
+ "projectV2": map[string]any{
+ "id": "PVT_project1",
+ },
+ },
+ }),
+ ),
+ // Mock addProjectV2ItemById mutation
+ githubv4mock.NewMutationMatcher(
+ struct {
+ AddProjectV2ItemByID struct {
+ Item struct {
+ ID githubv4.ID
+ }
+ } `graphql:"addProjectV2ItemById(input: $input)"`
+ }{},
+ githubv4.AddProjectV2ItemByIdInput{
+ ProjectID: githubv4.ID("PVT_project1"),
+ ContentID: githubv4.ID("I_issue123"),
+ },
+ nil,
+ githubv4mock.DataResponse(map[string]any{
+ "addProjectV2ItemById": map[string]any{
+ "item": map[string]any{
+ "id": "PVTI_item1",
+ },
+ },
+ }),
+ ),
+ )
+
+ client := githubv4.NewClient(mockedClient)
+ deps := BaseDeps{
+ GQLClient: 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_owner": "item-owner",
+ "item_repo": "item-repo",
+ "issue_number": 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"])
+ assert.Contains(t, response["message"], "Successfully added")
+ })
+
+ t.Run("success user with pull request", func(t *testing.T) {
+ mockedClient := githubv4mock.NewMockedHTTPClient(
+ // Mock resolvePullRequestNodeID query
+ githubv4mock.NewQueryMatcher(
+ struct {
+ Repository struct {
+ PullRequest struct {
+ ID githubv4.ID
+ } `graphql:"pullRequest(number: $prNumber)"`
+ } `graphql:"repository(owner: $owner, name: $repo)"`
+ }{},
+ map[string]any{
+ "owner": githubv4.String("item-owner"),
+ "repo": githubv4.String("item-repo"),
+ "prNumber": githubv4.Int(456),
+ },
+ githubv4mock.DataResponse(map[string]any{
+ "repository": map[string]any{
+ "pullRequest": map[string]any{
+ "id": "PR_pr456",
+ },
+ },
+ }),
+ ),
+ // Mock project ID query for user
+ githubv4mock.NewQueryMatcher(
+ struct {
+ User struct {
+ ProjectV2 struct {
+ ID githubv4.ID
+ } `graphql:"projectV2(number: $projectNumber)"`
+ } `graphql:"user(login: $owner)"`
+ }{},
+ map[string]any{
+ "owner": githubv4.String("octo-user"),
+ "projectNumber": githubv4.Int(2),
+ },
+ githubv4mock.DataResponse(map[string]any{
+ "user": map[string]any{
+ "projectV2": map[string]any{
+ "id": "PVT_project2",
+ },
+ },
+ }),
+ ),
+ // Mock addProjectV2ItemById mutation
+ githubv4mock.NewMutationMatcher(
+ struct {
+ AddProjectV2ItemByID struct {
+ Item struct {
+ ID githubv4.ID
+ }
+ } `graphql:"addProjectV2ItemById(input: $input)"`
+ }{},
+ githubv4.AddProjectV2ItemByIdInput{
+ ProjectID: githubv4.ID("PVT_project2"),
+ ContentID: githubv4.ID("PR_pr456"),
+ },
+ nil,
+ githubv4mock.DataResponse(map[string]any{
+ "addProjectV2ItemById": map[string]any{
+ "item": map[string]any{
+ "id": "PVTI_item2",
+ },
+ },
+ }),
+ ),
+ )
+
+ client := githubv4.NewClient(mockedClient)
+ deps := BaseDeps{
+ GQLClient: client,
+ }
+ handler := toolDef.Handler(deps)
+ request := createMCPRequest(map[string]any{
+ "method": "add_project_item",
+ "owner": "octo-user",
+ "owner_type": "user",
+ "project_number": float64(2),
+ "item_owner": "item-owner",
+ "item_repo": "item-repo",
+ "pull_request_number": float64(456),
+ "item_type": "pull_request",
+ })
+ result, err := handler(ContextWithDeps(context.Background(), deps), &request)
+
+ require.NoError(t, err)
+ require.False(t, result.IsError)
+
+ textContent := getTextResult(t, result)
+ var response map[string]any
+ err = json.Unmarshal([]byte(textContent.Text), &response)
+ require.NoError(t, err)
+ assert.NotNil(t, response["id"])
+ assert.Contains(t, response["message"], "Successfully added")
+ })
+
+ t.Run("missing item_type", func(t *testing.T) {
+ mockedClient := githubv4mock.NewMockedHTTPClient()
+ client := githubv4.NewClient(mockedClient)
+ deps := BaseDeps{
+ GQLClient: 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_owner": "item-owner",
+ "item_repo": "item-repo",
+ "issue_number": 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 := githubv4mock.NewMockedHTTPClient()
+ client := githubv4.NewClient(mockedClient)
+ deps := BaseDeps{
+ GQLClient: 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_owner": "item-owner",
+ "item_repo": "item-repo",
+ "issue_number": 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 := githubv4mock.NewMockedHTTPClient()
+ client := githubv4.NewClient(mockedClient)
+ deps := BaseDeps{
+ GQLClient: 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..a11fe29a5 100644
--- a/pkg/github/pullrequests.go
+++ b/pkg/github/pullrequests.go
@@ -15,9 +15,9 @@ 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/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 +69,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 {
@@ -99,7 +100,7 @@ Possible options:
switch method {
case "get":
- result, err := GetPullRequest(ctx, client, deps.GetRepoAccessCache(), owner, repo, pullNumber, deps.GetFlags())
+ result, err := GetPullRequest(ctx, client, deps, owner, repo, pullNumber)
return result, nil, err
case "get_diff":
result, err := GetPullRequestDiff(ctx, client, owner, repo, pullNumber)
@@ -119,13 +120,13 @@ Possible options:
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
- result, err := GetPullRequestReviewComments(ctx, gqlClient, deps.GetRepoAccessCache(), owner, repo, pullNumber, cursorPagination, deps.GetFlags())
+ result, err := GetPullRequestReviewComments(ctx, gqlClient, deps, owner, repo, pullNumber, cursorPagination)
return result, nil, err
case "get_reviews":
- result, err := GetPullRequestReviews(ctx, client, deps.GetRepoAccessCache(), owner, repo, pullNumber, deps.GetFlags())
+ result, err := GetPullRequestReviews(ctx, client, deps, owner, repo, pullNumber)
return result, nil, err
case "get_comments":
- result, err := GetIssueComments(ctx, client, deps.GetRepoAccessCache(), owner, repo, pullNumber, pagination, deps.GetFlags())
+ result, err := GetIssueComments(ctx, client, deps, owner, repo, pullNumber, pagination)
return result, nil, err
default:
return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil
@@ -133,7 +134,13 @@ Possible options:
})
}
-func GetPullRequest(ctx context.Context, client *github.Client, cache *lockdown.RepoAccessCache, owner, repo string, pullNumber int, ff FeatureFlags) (*mcp.CallToolResult, error) {
+func GetPullRequest(ctx context.Context, client *github.Client, deps ToolDependencies, owner, repo string, pullNumber int) (*mcp.CallToolResult, error) {
+ cache, err := deps.GetRepoAccessCache(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get repo access cache: %w", err)
+ }
+ ff := deps.GetFlags(ctx)
+
pr, resp, err := client.PullRequests.Get(ctx, owner, repo, pullNumber)
if err != nil {
return ghErrors.NewGitHubAPIErrorResponse(ctx,
@@ -338,7 +345,13 @@ type pageInfoFragment struct {
EndCursor githubv4.String
}
-func GetPullRequestReviewComments(ctx context.Context, gqlClient *githubv4.Client, cache *lockdown.RepoAccessCache, owner, repo string, pullNumber int, pagination CursorPaginationParams, ff FeatureFlags) (*mcp.CallToolResult, error) {
+func GetPullRequestReviewComments(ctx context.Context, gqlClient *githubv4.Client, deps ToolDependencies, owner, repo string, pullNumber int, pagination CursorPaginationParams) (*mcp.CallToolResult, error) {
+ cache, err := deps.GetRepoAccessCache(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get repo access cache: %w", err)
+ }
+ ff := deps.GetFlags(ctx)
+
// Convert pagination parameters to GraphQL format
gqlParams, err := pagination.ToGraphQLParams()
if err != nil {
@@ -419,7 +432,13 @@ func GetPullRequestReviewComments(ctx context.Context, gqlClient *githubv4.Clien
return utils.NewToolResultText(string(r)), nil
}
-func GetPullRequestReviews(ctx context.Context, client *github.Client, cache *lockdown.RepoAccessCache, owner, repo string, pullNumber int, ff FeatureFlags) (*mcp.CallToolResult, error) {
+func GetPullRequestReviews(ctx context.Context, client *github.Client, deps ToolDependencies, owner, repo string, pullNumber int) (*mcp.CallToolResult, error) {
+ cache, err := deps.GetRepoAccessCache(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get repo access cache: %w", err)
+ }
+ ff := deps.GetFlags(ctx)
+
reviews, resp, err := client.PullRequests.ListReviews(ctx, owner, repo, pullNumber, nil)
if err != nil {
return ghErrors.NewGitHubAPIErrorResponse(ctx,
@@ -518,6 +537,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 +689,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 {
@@ -898,6 +919,97 @@ func UpdatePullRequest(t translations.TranslationHelperFunc) inventory.ServerToo
})
}
+// AddReplyToPullRequestComment creates a tool to add a reply to an existing pull request comment.
+func AddReplyToPullRequestComment(t translations.TranslationHelperFunc) inventory.ServerTool {
+ schema := &jsonschema.Schema{
+ Type: "object",
+ Properties: map[string]*jsonschema.Schema{
+ "owner": {
+ Type: "string",
+ Description: "Repository owner",
+ },
+ "repo": {
+ Type: "string",
+ Description: "Repository name",
+ },
+ "pullNumber": {
+ Type: "number",
+ Description: "Pull request number",
+ },
+ "commentId": {
+ Type: "number",
+ Description: "The ID of the comment to reply to",
+ },
+ "body": {
+ Type: "string",
+ Description: "The text of the reply",
+ },
+ },
+ Required: []string{"owner", "repo", "pullNumber", "commentId", "body"},
+ }
+
+ return NewTool(
+ ToolsetMetadataPullRequests,
+ mcp.Tool{
+ Name: "add_reply_to_pull_request_comment",
+ Description: t("TOOL_ADD_REPLY_TO_PULL_REQUEST_COMMENT_DESCRIPTION", "Add a reply to an existing pull request comment. This creates a new comment that is linked as a reply to the specified comment."),
+ Annotations: &mcp.ToolAnnotations{
+ Title: t("TOOL_ADD_REPLY_TO_PULL_REQUEST_COMMENT_USER_TITLE", "Add reply to pull request comment"),
+ ReadOnlyHint: false,
+ },
+ 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 {
+ return utils.NewToolResultError(err.Error()), nil, nil
+ }
+ repo, err := RequiredParam[string](args, "repo")
+ if err != nil {
+ return utils.NewToolResultError(err.Error()), nil, nil
+ }
+ pullNumber, err := RequiredInt(args, "pullNumber")
+ if err != nil {
+ return utils.NewToolResultError(err.Error()), nil, nil
+ }
+ commentID, err := RequiredInt(args, "commentId")
+ if err != nil {
+ return utils.NewToolResultError(err.Error()), nil, nil
+ }
+ body, err := RequiredParam[string](args, "body")
+ if err != nil {
+ return utils.NewToolResultError(err.Error()), nil, nil
+ }
+
+ client, err := deps.GetClient(ctx)
+ if err != nil {
+ return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil
+ }
+
+ comment, resp, err := client.PullRequests.CreateCommentInReplyTo(ctx, owner, repo, pullNumber, body, int64(commentID))
+ if err != nil {
+ return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to add reply to pull request comment", resp, err), nil, nil
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode != http.StatusCreated {
+ bodyBytes, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return utils.NewToolResultErrorFromErr("failed to read response body", err), nil, nil
+ }
+ return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to add reply to pull request comment", resp, bodyBytes), nil, nil
+ }
+
+ r, err := json.Marshal(comment)
+ if err != nil {
+ return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil
+ }
+
+ return utils.NewToolResultText(string(r)), nil, nil
+ })
+}
+
// ListPullRequests creates a tool to list and filter repository pull requests.
func ListPullRequests(t translations.TranslationHelperFunc) inventory.ServerTool {
schema := &jsonschema.Schema{
@@ -950,6 +1062,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 +1199,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 +1317,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 +1360,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 +1487,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 {
@@ -1493,7 +1610,7 @@ func SubmitPendingPullRequestReview(ctx context.Context, client *githubv4.Client
"prNum": githubv4.Int(params.PullNumber),
}
- if err := client.Query(context.Background(), &getLatestReviewForViewerQuery, vars); err != nil {
+ if err := client.Query(ctx, &getLatestReviewForViewerQuery, vars); err != nil {
return ghErrors.NewGitHubGraphQLErrorResponse(ctx,
"failed to get latest review for current user",
err,
@@ -1578,7 +1695,7 @@ func DeletePendingPullRequestReview(ctx context.Context, client *githubv4.Client
"prNum": githubv4.Int(params.PullNumber),
}
- if err := client.Query(context.Background(), &getLatestReviewForViewerQuery, vars); err != nil {
+ if err := client.Query(ctx, &getLatestReviewForViewerQuery, vars); err != nil {
return ghErrors.NewGitHubGraphQLErrorResponse(ctx,
"failed to get latest review for current user",
err,
@@ -1694,6 +1811,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
@@ -1751,7 +1869,7 @@ func AddCommentToPendingReview(t translations.TranslationHelperFunc) inventory.S
"prNum": githubv4.Int(params.PullNumber),
}
- if err := client.Query(context.Background(), &getLatestReviewForViewerQuery, vars); err != nil {
+ if err := client.Query(ctx, &getLatestReviewForViewerQuery, vars); err != nil {
return ghErrors.NewGitHubGraphQLErrorResponse(ctx,
"failed to get latest review for current user",
err,
@@ -1846,6 +1964,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..61a4ad7f1 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,
},
}
@@ -3353,3 +3227,167 @@ func getLatestPendingReviewQuery(p getLatestPendingReviewQueryParams) githubv4mo
),
)
}
+
+func TestAddReplyToPullRequestComment(t *testing.T) {
+ t.Parallel()
+
+ // Verify tool definition once
+ serverTool := AddReplyToPullRequestComment(translations.NullTranslationHelper)
+ tool := serverTool.Tool
+ require.NoError(t, toolsnaps.Test(tool.Name, tool))
+
+ assert.Equal(t, "add_reply_to_pull_request_comment", tool.Name)
+ assert.NotEmpty(t, tool.Description)
+ schema := tool.InputSchema.(*jsonschema.Schema)
+ assert.Contains(t, schema.Properties, "owner")
+ assert.Contains(t, schema.Properties, "repo")
+ assert.Contains(t, schema.Properties, "pullNumber")
+ assert.Contains(t, schema.Properties, "commentId")
+ assert.Contains(t, schema.Properties, "body")
+ assert.ElementsMatch(t, schema.Required, []string{"owner", "repo", "pullNumber", "commentId", "body"})
+
+ // Setup mock reply comment for success case
+ mockReplyComment := &github.PullRequestComment{
+ ID: github.Ptr(int64(456)),
+ Body: github.Ptr("This is a reply to the comment"),
+ InReplyTo: github.Ptr(int64(123)),
+ HTMLURL: github.Ptr("https://github.com/owner/repo/pull/42#discussion_r456"),
+ User: &github.User{
+ Login: github.Ptr("responder"),
+ },
+ CreatedAt: &github.Timestamp{Time: time.Now()},
+ UpdatedAt: &github.Timestamp{Time: time.Now()},
+ }
+
+ tests := []struct {
+ name string
+ mockedClient *http.Client
+ requestArgs map[string]interface{}
+ expectToolError bool
+ expectedToolErrMsg string
+ }{
+ {
+ name: "successful reply to pull request comment",
+ requestArgs: map[string]interface{}{
+ "owner": "owner",
+ "repo": "repo",
+ "pullNumber": float64(42),
+ "commentId": float64(123),
+ "body": "This is a reply to the comment",
+ },
+ mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
+ PostReposPullsCommentsByOwnerByRepoByPullNumber: func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusCreated)
+ responseData, _ := json.Marshal(mockReplyComment)
+ _, _ = w.Write(responseData)
+ },
+ }),
+ },
+ {
+ name: "missing required parameter owner",
+ requestArgs: map[string]interface{}{
+ "repo": "repo",
+ "pullNumber": float64(42),
+ "commentId": float64(123),
+ "body": "This is a reply to the comment",
+ },
+ expectToolError: true,
+ expectedToolErrMsg: "missing required parameter: owner",
+ },
+ {
+ name: "missing required parameter repo",
+ requestArgs: map[string]interface{}{
+ "owner": "owner",
+ "pullNumber": float64(42),
+ "commentId": float64(123),
+ "body": "This is a reply to the comment",
+ },
+ expectToolError: true,
+ expectedToolErrMsg: "missing required parameter: repo",
+ },
+ {
+ name: "missing required parameter pullNumber",
+ requestArgs: map[string]interface{}{
+ "owner": "owner",
+ "repo": "repo",
+ "commentId": float64(123),
+ "body": "This is a reply to the comment",
+ },
+ expectToolError: true,
+ expectedToolErrMsg: "missing required parameter: pullNumber",
+ },
+ {
+ name: "missing required parameter commentId",
+ requestArgs: map[string]interface{}{
+ "owner": "owner",
+ "repo": "repo",
+ "pullNumber": float64(42),
+ "body": "This is a reply to the comment",
+ },
+ expectToolError: true,
+ expectedToolErrMsg: "missing required parameter: commentId",
+ },
+ {
+ name: "missing required parameter body",
+ requestArgs: map[string]interface{}{
+ "owner": "owner",
+ "repo": "repo",
+ "pullNumber": float64(42),
+ "commentId": float64(123),
+ },
+ expectToolError: true,
+ expectedToolErrMsg: "missing required parameter: body",
+ },
+ {
+ name: "API error when adding reply",
+ mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
+ PostReposPullsCommentsByOwnerByRepoByPullNumber: func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusNotFound)
+ _, _ = w.Write([]byte(`{"message": "Not Found"}`))
+ },
+ }),
+ requestArgs: map[string]interface{}{
+ "owner": "owner",
+ "repo": "repo",
+ "pullNumber": float64(42),
+ "commentId": float64(123),
+ "body": "This is a reply to the comment",
+ },
+ expectToolError: true,
+ expectedToolErrMsg: "failed to add reply to pull request comment",
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ // Setup client with mock
+ client := github.NewClient(tc.mockedClient)
+ serverTool := AddReplyToPullRequestComment(translations.NullTranslationHelper)
+ deps := BaseDeps{
+ Client: client,
+ }
+ handler := serverTool.Handler(deps)
+
+ // Create call request
+ request := createMCPRequest(tc.requestArgs)
+
+ // Call handler
+ result, err := handler(ContextWithDeps(context.Background(), deps), &request)
+ require.NoError(t, err)
+
+ if tc.expectToolError {
+ require.True(t, result.IsError)
+ errorContent := getErrorResult(t, result)
+ assert.Contains(t, errorContent.Text, tc.expectedToolErrMsg)
+ return
+ }
+
+ // Parse the result and verify it's not an error
+ require.False(t, result.IsError)
+ textContent := getTextResult(t, result)
+ assert.Contains(t, textContent.Text, "This is a reply to the comment")
+ })
+ }
+}
diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go
index d8d2b27b3..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 {
@@ -671,6 +677,8 @@ func GetFileContents(t translations.TranslationHelperFunc) inventory.ServerTool
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
+ originalRef := ref
+
sha, err := OptionalParam[string](args, "sha")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
@@ -681,7 +689,7 @@ func GetFileContents(t translations.TranslationHelperFunc) inventory.ServerTool
return utils.NewToolResultError("failed to get GitHub client"), nil, nil
}
- rawOpts, err := resolveGitReference(ctx, client, owner, repo, ref, sha)
+ rawOpts, fallbackUsed, err := resolveGitReference(ctx, client, owner, repo, ref, sha)
if err != nil {
return utils.NewToolResultError(fmt.Sprintf("failed to resolve git reference: %s", err)), nil, nil
}
@@ -724,7 +732,7 @@ func GetFileContents(t translations.TranslationHelperFunc) inventory.ServerTool
// If the raw content is found, return it directly
body, err := io.ReadAll(resp.Body)
if err != nil {
- return utils.NewToolResultError("failed to read response body"), nil, nil
+ return ghErrors.NewGitHubRawAPIErrorResponse(ctx, "failed to get raw repository content", resp, err), nil, nil
}
contentType := resp.Header.Get("Content-Type")
@@ -747,6 +755,12 @@ func GetFileContents(t translations.TranslationHelperFunc) inventory.ServerTool
}
}
+ // main branch ref passed in ref parameter but it doesn't exist - default branch was used
+ var successNote string
+ if fallbackUsed {
+ successNote = fmt.Sprintf(" Note: the provided ref '%s' does not exist, default branch '%s' was used instead.", originalRef, rawOpts.Ref)
+ }
+
// Determine if content is text or binary
isTextContent := strings.HasPrefix(contentType, "text/") ||
contentType == "application/json" ||
@@ -762,9 +776,9 @@ func GetFileContents(t translations.TranslationHelperFunc) inventory.ServerTool
}
// Include SHA in the result metadata
if fileSHA != "" {
- return utils.NewToolResultResource(fmt.Sprintf("successfully downloaded text file (SHA: %s)", fileSHA), result), nil, nil
+ return utils.NewToolResultResource(fmt.Sprintf("successfully downloaded text file (SHA: %s)", fileSHA)+successNote, result), nil, nil
}
- return utils.NewToolResultResource("successfully downloaded text file", result), nil, nil
+ return utils.NewToolResultResource("successfully downloaded text file"+successNote, result), nil, nil
}
result := &mcp.ResourceContents{
@@ -774,9 +788,9 @@ func GetFileContents(t translations.TranslationHelperFunc) inventory.ServerTool
}
// Include SHA in the result metadata
if fileSHA != "" {
- return utils.NewToolResultResource(fmt.Sprintf("successfully downloaded binary file (SHA: %s)", fileSHA), result), nil, nil
+ return utils.NewToolResultResource(fmt.Sprintf("successfully downloaded binary file (SHA: %s)", fileSHA)+successNote, result), nil, nil
}
- return utils.NewToolResultResource("successfully downloaded binary file", result), nil, nil
+ return utils.NewToolResultResource("successfully downloaded binary file"+successNote, result), nil, nil
}
// Raw API call failed
@@ -826,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 {
@@ -932,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 {
@@ -1111,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 {
@@ -1241,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 {
@@ -1271,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 {
@@ -1320,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,
@@ -1329,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,
@@ -1345,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
@@ -1398,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 {
@@ -1480,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 {
@@ -1573,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 {
@@ -1647,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 {
@@ -1715,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 {
@@ -1762,175 +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
-}
-
-// 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.
-//
-// 2. If no `sha` is provided, 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, error) {
- // 1) If SHA explicitly provided, it's the highest priority.
- if sha != "" {
- return &raw.ContentOpts{Ref: "", SHA: sha}, 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
-
- switch {
- case originalRef == "":
- // 2a) If ref is empty, determine the default branch.
- 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)
- }
- ref = fmt.Sprintf("refs/heads/%s", repoInfo.GetDefaultBranch())
- 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 {
- return nil, 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, 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, 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 {
- _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get final reference", resp, err)
- return nil, fmt.Errorf("failed to get final reference for %q: %w", ref, err)
- }
- }
-
- sha = reference.GetObject().GetSHA()
- return &raw.ContentOpts{Ref: ref, SHA: sha}, 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(
@@ -1962,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 {
@@ -2089,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 {
@@ -2153,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 9d7501f35..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"
@@ -69,39 +69,29 @@ func Test_GetFileContents(t *testing.T) {
expectedResult interface{}
expectedErrMsg string
expectStatus int
+ expectedMsg string // optional: expected message text to verify in result
}{
{
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",
@@ -117,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",
@@ -162,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",
@@ -207,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": ""}}`))
- }),
+ 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(
- mock.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",
@@ -247,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",
@@ -291,30 +228,110 @@ 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) {
+ name: "successful text content fetch with note when ref falls back to default branch",
+ 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(`{"ref": "refs/heads/main", "object": {"sha": ""}}`))
- }),
- ),
- 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"}`))
- }),
- ),
- mock.WithRequestMatchHandler(
- raw.GetRawReposContentsByOwnerByRepoByPath,
- http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ }
+ },
+ "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/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",
+ "path": "README.md",
+ "ref": "main",
+ },
+ expectError: false,
+ expectedResult: mcp.ResourceContents{
+ URI: "repo://owner/repo/abc123def456/contents/README.md",
+ Text: "# Test Repository\n\nThis is a test repository.",
+ MIMEType: "text/markdown",
+ },
+ expectedMsg: " Note: the provided ref 'main' does not exist, default branch 'refs/heads/develop' was used instead.",
+ },
+ {
+ name: "content fetch fails",
+ 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",
@@ -358,6 +375,14 @@ func Test_GetFileContents(t *testing.T) {
// Handle both text and blob resources
resource := getResourceResult(t, result)
assert.Equal(t, expected, *resource)
+
+ // If expectedMsg is set, verify the message text
+ if tc.expectedMsg != "" {
+ require.Len(t, result.Content, 2)
+ textContent, ok := result.Content[0].(*mcp.TextContent)
+ require.True(t, ok, "expected Content[0] to be TextContent")
+ assert.Contains(t, textContent.Text, tc.expectedMsg)
+ }
case []*github.RepositoryContent:
// Directory content fetch returns a text result (JSON array)
textContent := getTextResult(t, result)
@@ -418,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",
@@ -433,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",
@@ -534,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",
@@ -555,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",
@@ -584,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",
@@ -603,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",
@@ -623,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",
@@ -744,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",
@@ -760,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",
@@ -926,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",
@@ -941,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",
@@ -965,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",
@@ -987,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",
@@ -1110,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",
@@ -1135,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",
@@ -1162,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",
@@ -1184,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",
@@ -1227,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",
@@ -1254,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",
@@ -1290,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",
@@ -1326,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",
@@ -1453,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",
@@ -1480,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",
@@ -1508,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,
@@ -1532,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"}`))
@@ -1656,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{}{
@@ -1691,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",
@@ -1702,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,
@@ -1733,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{}{
@@ -1748,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,
),
),
@@ -1776,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,
),
),
@@ -1805,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),
),
),
@@ -1823,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),
),
),
@@ -1857,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),
),
),
@@ -1889,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 {
@@ -1973,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
}{
@@ -1984,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,
),
},
@@ -1997,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",
},
@@ -2006,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",
},
@@ -2015,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,
}
@@ -2112,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{}{
@@ -2141,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",
@@ -2152,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,
@@ -2179,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"}`))
@@ -2289,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",
@@ -2309,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"}`))
@@ -2415,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",
@@ -2425,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",
@@ -2445,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"}`))
@@ -2464,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"}`))
@@ -2568,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,
),
),
@@ -2583,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"}`))
@@ -2659,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,
),
),
@@ -2674,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"}`))
@@ -2756,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,
),
),
@@ -2772,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",
@@ -2782,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",
@@ -2792,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",
@@ -2802,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"}`))
@@ -2821,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"}`))
@@ -2889,6 +3273,72 @@ func Test_GetReleaseByTag(t *testing.T) {
}
}
+func Test_looksLikeSHA(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ expected bool
+ }{
+ {
+ name: "full 40-character SHA",
+ input: "abc123def456abc123def456abc123def456abc1",
+ expected: true,
+ },
+ {
+ name: "too short",
+ input: "abc123def456abc123def45",
+ expected: false,
+ },
+ {
+ name: "too long - 41 characters",
+ input: "abc123def456abc123def456abc123def456abc12",
+ expected: false,
+ },
+ {
+ name: "contains invalid character - space",
+ input: "abc123def456abc123def456 bc123def456abc1",
+ expected: false,
+ },
+ {
+ name: "contains invalid character - dash",
+ input: "abc123def456abc123d-f456abc123def456abc1",
+ expected: false,
+ },
+ {
+ name: "contains invalid character - g",
+ input: "abc123def456gbc123def456abc123def456abc1",
+ expected: false,
+ },
+ {
+ name: "branch name with slash",
+ input: "feature/branch",
+ expected: false,
+ },
+ {
+ name: "empty string",
+ input: "",
+ expected: false,
+ },
+ {
+ name: "all zeros SHA",
+ input: "0000000000000000000000000000000000000000",
+ expected: true,
+ },
+ {
+ name: "all f's SHA",
+ input: "ffffffffffffffffffffffffffffffffffffffff",
+ expected: true,
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ result := looksLikeSHA(tc.input)
+ assert.Equal(t, tc.expected, result)
+ })
+ }
+}
+
func Test_filterPaths(t *testing.T) {
tests := []struct {
name string
@@ -3004,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",
@@ -3016,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)
@@ -3045,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)
@@ -3067,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)
@@ -3093,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"):
@@ -3123,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)
@@ -3145,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)
@@ -3167,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)
@@ -3186,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)
@@ -3203,13 +3653,26 @@ func Test_resolveGitReference(t *testing.T) {
},
expectError: false,
},
+ {
+ name: "ref looks like full SHA with empty sha parameter",
+ ref: "abc123def456abc123def456abc123def456abc1",
+ sha: "",
+ mockSetup: func() *http.Client {
+ // No API calls should be made when ref looks like SHA
+ return NewMockedHTTPClient()
+ },
+ expectedOutput: &raw.ContentOpts{
+ SHA: "abc123def456abc123def456abc123def456abc1",
+ },
+ expectError: false,
+ },
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
// Setup client with mock
client := github.NewClient(tc.mockSetup())
- opts, err := resolveGitReference(ctx, client, owner, repo, tc.ref, tc.sha)
+ opts, _, err := resolveGitReference(ctx, client, owner, repo, tc.ref, tc.sha)
if tc.expectError {
require.Error(t, err)
@@ -3304,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))
}),
),
),
@@ -3319,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))
}),
),
),
@@ -3336,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"}`))
@@ -3418,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)
}),
@@ -3434,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"}`))
@@ -3509,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)
}),
@@ -3525,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..9cdd4db19
--- /dev/null
+++ b/pkg/github/scope_filter_test.go
@@ -0,0 +1,191 @@
+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, err := inventory.NewBuilder().
+ SetTools(tools).
+ WithToolsets([]string{"test"}).
+ WithFilter(filter).
+ Build()
+ require.NoError(t, err)
+
+ // 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/server.go b/pkg/github/server.go
index 8248da58f..9a602e153 100644
--- a/pkg/github/server.go
+++ b/pkg/github/server.go
@@ -3,433 +3,203 @@ package github
import (
"context"
"encoding/json"
- "errors"
"fmt"
- "strconv"
+ "log/slog"
"strings"
+ "time"
+ 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/translations"
"github.com/github/github-mcp-server/pkg/utils"
- "github.com/google/go-github/v79/github"
- "github.com/google/jsonschema-go/jsonschema"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
-// NewServer creates a new GitHub MCP server with the specified GH client and logger.
+type MCPServerConfig struct {
+ // Version of the server
+ Version string
-func NewServer(version string, opts *mcp.ServerOptions) *mcp.Server {
- if opts == nil {
- opts = &mcp.ServerOptions{}
- }
+ // GitHub Host to target for API requests (e.g. github.com or github.enterprise.com)
+ Host string
- // Create a new MCP server
- s := mcp.NewServer(&mcp.Implementation{
- Name: "github-mcp-server",
- Title: "GitHub MCP Server",
- Version: version,
- Icons: octicons.Icons("mark-github"),
- }, opts)
+ // GitHub Token to authenticate with the GitHub API
+ Token string
- return s
-}
+ // EnabledToolsets is a list of toolsets to enable
+ // See: https://github.com/github/github-mcp-server?tab=readme-ov-file#tool-configuration
+ EnabledToolsets []string
-func CompletionsHandler(getClient GetClientFn) func(ctx context.Context, req *mcp.CompleteRequest) (*mcp.CompleteResult, error) {
- return func(ctx context.Context, req *mcp.CompleteRequest) (*mcp.CompleteResult, error) {
- switch req.Params.Ref.Type {
- case "ref/resource":
- if strings.HasPrefix(req.Params.Ref.URI, "repo://") {
- return RepositoryResourceCompletionHandler(getClient)(ctx, req)
- }
- return nil, fmt.Errorf("unsupported resource URI: %s", req.Params.Ref.URI)
- case "ref/prompt":
- return nil, nil
- default:
- return nil, fmt.Errorf("unsupported ref type: %s", req.Params.Ref.Type)
- }
- }
-}
+ // EnabledTools is a list of specific tools to enable (additive to toolsets)
+ // When specified, these tools are registered in addition to any specified toolset tools
+ EnabledTools []string
-// OptionalParamOK is a helper function that can be used to fetch a requested parameter from the request.
-// It returns the value, a boolean indicating if the parameter was present, and an error if the type is wrong.
-func OptionalParamOK[T any, A map[string]any](args A, p string) (value T, ok bool, err error) {
- // Check if the parameter is present in the request
- val, exists := args[p]
- if !exists {
- // Not present, return zero value, false, no error
- return
- }
+ // EnabledFeatures is a list of feature flags that are enabled
+ // Items with FeatureFlagEnable matching an entry in this list will be available
+ EnabledFeatures []string
- // Check if the parameter is of the expected type
- value, ok = val.(T)
- if !ok {
- // Present but wrong type
- err = fmt.Errorf("parameter %s is not of type %T, is %T", p, value, val)
- ok = true // Set ok to true because the parameter *was* present, even if wrong type
- return
- }
+ // Whether to enable dynamic toolsets
+ // See: https://github.com/github/github-mcp-server?tab=readme-ov-file#dynamic-tool-discovery
+ DynamicToolsets bool
- // Present and correct type
- ok = true
- return
-}
+ // ReadOnly indicates if we should only offer read-only tools
+ ReadOnly bool
-// isAcceptedError checks if the error is an accepted error.
-func isAcceptedError(err error) bool {
- var acceptedError *github.AcceptedError
- return errors.As(err, &acceptedError)
-}
-
-// RequiredParam is a helper function that can be used to fetch a requested parameter from the request.
-// It does the following checks:
-// 1. Checks if the parameter is present in the request.
-// 2. Checks if the parameter is of the expected type.
-// 3. Checks if the parameter is not empty, i.e: non-zero value
-func RequiredParam[T comparable](args map[string]any, p string) (T, error) {
- var zero T
-
- // Check if the parameter is present in the request
- if _, ok := args[p]; !ok {
- return zero, fmt.Errorf("missing required parameter: %s", p)
- }
+ // Translator provides translated text for the server tooling
+ Translator translations.TranslationHelperFunc
- // Check if the parameter is of the expected type
- val, ok := args[p].(T)
- if !ok {
- return zero, fmt.Errorf("parameter %s is not of type %T", p, zero)
- }
+ // Content window size
+ ContentWindowSize int
- if val == zero {
- return zero, fmt.Errorf("missing required parameter: %s", p)
- }
+ // LockdownMode indicates if we should enable lockdown mode
+ LockdownMode bool
- return val, nil
-}
+ // InsidersMode indicates if we should enable experimental features
+ InsidersMode bool
-// RequiredInt is a helper function that can be used to fetch a requested parameter from the request.
-// It does the following checks:
-// 1. Checks if the parameter is present in the request.
-// 2. Checks if the parameter is of the expected type.
-// 3. Checks if the parameter is not empty, i.e: non-zero value
-func RequiredInt(args map[string]any, p string) (int, error) {
- v, err := RequiredParam[float64](args, p)
- if err != nil {
- return 0, err
- }
- return int(v), nil
-}
+ // Logger is used for logging within the server
+ Logger *slog.Logger
+ // RepoAccessTTL overrides the default TTL for repository access cache entries.
+ RepoAccessTTL *time.Duration
-// RequiredBigInt is a helper function that can be used to fetch a requested parameter from the request.
-// It does the following checks:
-// 1. Checks if the parameter is present in the request.
-// 2. Checks if the parameter is of the expected type (float64).
-// 3. Checks if the parameter is not empty, i.e: non-zero value.
-// 4. Validates that the float64 value can be safely converted to int64 without truncation.
-func RequiredBigInt(args map[string]any, p string) (int64, error) {
- v, err := RequiredParam[float64](args, p)
- if err != nil {
- return 0, err
- }
+ // 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
- result := int64(v)
- // Check if converting back produces the same value to avoid silent truncation
- if float64(result) != v {
- return 0, fmt.Errorf("parameter %s value %f is too large to fit in int64", p, v)
- }
- return result, nil
+ // Additional server options to apply
+ ServerOptions []MCPServerOption
}
-// OptionalParam is a helper function that can be used to fetch a requested parameter from the request.
-// It does the following checks:
-// 1. Checks if the parameter is present in the request, if not, it returns its zero-value
-// 2. If it is present, it checks if the parameter is of the expected type and returns it
-func OptionalParam[T any](args map[string]any, p string) (T, error) {
- var zero T
+type MCPServerOption func(*mcp.ServerOptions)
- // Check if the parameter is present in the request
- if _, ok := args[p]; !ok {
- return zero, nil
+func NewMCPServer(ctx context.Context, cfg *MCPServerConfig, deps ToolDependencies, inv *inventory.Inventory) (*mcp.Server, error) {
+ // Create the MCP server
+ serverOpts := &mcp.ServerOptions{
+ Instructions: inv.Instructions(),
+ Logger: cfg.Logger,
+ CompletionHandler: CompletionsHandler(deps.GetClient),
}
- // Check if the parameter is of the expected type
- if _, ok := args[p].(T); !ok {
- return zero, fmt.Errorf("parameter %s is not of type %T, is %T", p, zero, args[p])
+ // Apply any additional server options
+ for _, o := range cfg.ServerOptions {
+ o(serverOpts)
}
- return args[p].(T), nil
-}
-
-// OptionalIntParam is a helper function that can be used to fetch a requested parameter from the request.
-// It does the following checks:
-// 1. Checks if the parameter is present in the request, if not, it returns its zero-value
-// 2. If it is present, it checks if the parameter is of the expected type and returns it
-func OptionalIntParam(args map[string]any, p string) (int, error) {
- v, err := OptionalParam[float64](args, p)
- if err != nil {
- return 0, err
- }
- return int(v), nil
-}
-
-// OptionalIntParamWithDefault is a helper function that can be used to fetch a requested parameter from the request
-// similar to optionalIntParam, but it also takes a default value.
-func OptionalIntParamWithDefault(args map[string]any, p string, d int) (int, error) {
- v, err := OptionalIntParam(args, p)
- if err != nil {
- return 0, err
- }
- if v == 0 {
- return d, nil
- }
- return v, nil
-}
-
-// OptionalBoolParamWithDefault is a helper function that can be used to fetch a requested parameter from the request
-// similar to optionalBoolParam, but it also takes a default value.
-func OptionalBoolParamWithDefault(args map[string]any, p string, d bool) (bool, error) {
- _, ok := args[p]
- v, err := OptionalParam[bool](args, p)
- if err != nil {
- return false, err
- }
- if !ok {
- return d, nil
- }
- return v, nil
-}
-
-// OptionalStringArrayParam is a helper function that can be used to fetch a requested parameter from the request.
-// It does the following checks:
-// 1. Checks if the parameter is present in the request, if not, it returns its zero-value
-// 2. If it is present, iterates the elements and checks each is a string
-func OptionalStringArrayParam(args map[string]any, p string) ([]string, error) {
- // Check if the parameter is present in the request
- if _, ok := args[p]; !ok {
- return []string{}, nil
- }
-
- switch v := args[p].(type) {
- case nil:
- return []string{}, nil
- case []string:
- return v, nil
- case []any:
- strSlice := make([]string, len(v))
- for i, v := range v {
- s, ok := v.(string)
- if !ok {
- return []string{}, fmt.Errorf("parameter %s is not of type string, is %T", p, v)
- }
- strSlice[i] = s
+ // In dynamic mode, explicitly advertise capabilities since tools/resources/prompts
+ // may be enabled at runtime even if none are registered initially.
+ if cfg.DynamicToolsets {
+ serverOpts.Capabilities = &mcp.ServerCapabilities{
+ Tools: &mcp.ToolCapabilities{},
+ Resources: &mcp.ResourceCapabilities{},
+ Prompts: &mcp.PromptCapabilities{},
}
- return strSlice, nil
- default:
- return []string{}, fmt.Errorf("parameter %s could not be coerced to []string, is %T", p, args[p])
}
-}
-
-func convertStringSliceToBigIntSlice(s []string) ([]int64, error) {
- int64Slice := make([]int64, len(s))
- for i, str := range s {
- val, err := convertStringToBigInt(str, 0)
- if err != nil {
- return nil, fmt.Errorf("failed to convert element %d (%s) to int64: %w", i, str, err)
- }
- int64Slice[i] = val
- }
- return int64Slice, nil
-}
-func convertStringToBigInt(s string, def int64) (int64, error) {
- v, err := strconv.ParseInt(s, 10, 64)
- if err != nil {
- return def, fmt.Errorf("failed to convert string %s to int64: %w", s, err)
- }
- return v, nil
-}
+ ghServer := NewServer(cfg.Version, serverOpts)
-// OptionalBigIntArrayParam is a helper function that can be used to fetch a requested parameter from the request.
-// It does the following checks:
-// 1. Checks if the parameter is present in the request, if not, it returns an empty slice
-// 2. If it is present, iterates the elements, checks each is a string, and converts them to int64 values
-func OptionalBigIntArrayParam(args map[string]any, p string) ([]int64, error) {
- // Check if the parameter is present in the request
- if _, ok := args[p]; !ok {
- return []int64{}, nil
- }
+ // Add middlewares
+ ghServer.AddReceivingMiddleware(addGitHubAPIErrorToContext)
+ ghServer.AddReceivingMiddleware(InjectDepsMiddleware(deps))
- switch v := args[p].(type) {
- case nil:
- return []int64{}, nil
- case []string:
- return convertStringSliceToBigIntSlice(v)
- case []any:
- int64Slice := make([]int64, len(v))
- for i, v := range v {
- s, ok := v.(string)
- if !ok {
- return []int64{}, fmt.Errorf("parameter %s is not of type string, is %T", p, v)
- }
- val, err := convertStringToBigInt(s, 0)
- if err != nil {
- return []int64{}, fmt.Errorf("parameter %s: failed to convert element %d (%s) to int64: %w", p, i, s, err)
- }
- int64Slice[i] = val
- }
- return int64Slice, nil
- default:
- return []int64{}, fmt.Errorf("parameter %s could not be coerced to []int64, is %T", p, args[p])
+ if unrecognized := inv.UnrecognizedToolsets(); len(unrecognized) > 0 {
+ cfg.Logger.Warn("Warning: unrecognized toolsets ignored", "toolsets", strings.Join(unrecognized, ", "))
}
-}
-// WithPagination adds REST API pagination parameters to a tool.
-// https://docs.github.com/en/rest/using-the-rest-api/using-pagination-in-the-rest-api
-func WithPagination(schema *jsonschema.Schema) *jsonschema.Schema {
- schema.Properties["page"] = &jsonschema.Schema{
- Type: "number",
- Description: "Page number for pagination (min 1)",
- Minimum: jsonschema.Ptr(1.0),
- }
+ // Register GitHub tools/resources/prompts from the inventory.
+ // In dynamic mode with no explicit toolsets, this is a no-op since enabledToolsets
+ // is empty - users enable toolsets at runtime via the dynamic tools below (but can
+ // enable toolsets or tools explicitly that do need registration).
+ inv.RegisterAll(ctx, ghServer, deps)
- schema.Properties["perPage"] = &jsonschema.Schema{
- Type: "number",
- Description: "Results per page for pagination (min 1, max 100)",
- Minimum: jsonschema.Ptr(1.0),
- Maximum: jsonschema.Ptr(100.0),
+ // Register dynamic toolset management tools (enable/disable) - these are separate
+ // meta-tools that control the inventory, not part of the inventory itself
+ if cfg.DynamicToolsets {
+ registerDynamicTools(ghServer, inv, deps, cfg.Translator)
}
- return schema
+ return ghServer, nil
}
-// WithUnifiedPagination adds REST API pagination parameters to a tool.
-// GraphQL tools will use this and convert page/perPage to GraphQL cursor parameters internally.
-func WithUnifiedPagination(schema *jsonschema.Schema) *jsonschema.Schema {
- schema.Properties["page"] = &jsonschema.Schema{
- Type: "number",
- Description: "Page number for pagination (min 1)",
- Minimum: jsonschema.Ptr(1.0),
+// registerDynamicTools adds the dynamic toolset enable/disable tools to the server.
+func registerDynamicTools(server *mcp.Server, inventory *inventory.Inventory, deps ToolDependencies, t translations.TranslationHelperFunc) {
+ dynamicDeps := DynamicToolDependencies{
+ Server: server,
+ Inventory: inventory,
+ ToolDeps: deps,
+ T: t,
}
-
- schema.Properties["perPage"] = &jsonschema.Schema{
- Type: "number",
- Description: "Results per page for pagination (min 1, max 100)",
- Minimum: jsonschema.Ptr(1.0),
- Maximum: jsonschema.Ptr(100.0),
+ for _, tool := range DynamicTools(inventory) {
+ tool.RegisterFunc(server, dynamicDeps)
}
-
- schema.Properties["after"] = &jsonschema.Schema{
- Type: "string",
- Description: "Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs.",
- }
-
- return schema
}
-// WithCursorPagination adds only cursor-based pagination parameters to a tool (no page parameter).
-func WithCursorPagination(schema *jsonschema.Schema) *jsonschema.Schema {
- schema.Properties["perPage"] = &jsonschema.Schema{
- Type: "number",
- Description: "Results per page for pagination (min 1, max 100)",
- Minimum: jsonschema.Ptr(1.0),
- Maximum: jsonschema.Ptr(100.0),
+// ResolvedEnabledToolsets determines which toolsets should be enabled based on config.
+// Returns nil for "use defaults", empty slice for "none", or explicit list.
+func ResolvedEnabledToolsets(dynamicToolsets bool, enabledToolsets []string, enabledTools []string) []string {
+ // In dynamic mode, remove "all" and "default" since users enable toolsets on demand
+ if dynamicToolsets && enabledToolsets != nil {
+ enabledToolsets = RemoveToolset(enabledToolsets, string(ToolsetMetadataAll.ID))
+ enabledToolsets = RemoveToolset(enabledToolsets, string(ToolsetMetadataDefault.ID))
}
- schema.Properties["after"] = &jsonschema.Schema{
- Type: "string",
- Description: "Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs.",
+ if enabledToolsets != nil {
+ return enabledToolsets
}
-
- return schema
-}
-
-type PaginationParams struct {
- Page int
- PerPage int
- After string
-}
-
-// OptionalPaginationParams returns the "page", "perPage", and "after" parameters from the request,
-// or their default values if not present, "page" default is 1, "perPage" default is 30.
-// In future, we may want to make the default values configurable, or even have this
-// function returned from `withPagination`, where the defaults are provided alongside
-// the min/max values.
-func OptionalPaginationParams(args map[string]any) (PaginationParams, error) {
- page, err := OptionalIntParamWithDefault(args, "page", 1)
- if err != nil {
- return PaginationParams{}, err
- }
- perPage, err := OptionalIntParamWithDefault(args, "perPage", 30)
- if err != nil {
- return PaginationParams{}, err
- }
- after, err := OptionalParam[string](args, "after")
- if err != nil {
- return PaginationParams{}, err
- }
- return PaginationParams{
- Page: page,
- PerPage: perPage,
- After: after,
- }, nil
-}
-
-// OptionalCursorPaginationParams returns the "perPage" and "after" parameters from the request,
-// without the "page" parameter, suitable for cursor-based pagination only.
-func OptionalCursorPaginationParams(args map[string]any) (CursorPaginationParams, error) {
- perPage, err := OptionalIntParamWithDefault(args, "perPage", 30)
- if err != nil {
- return CursorPaginationParams{}, err
+ if dynamicToolsets {
+ // Dynamic mode with no toolsets specified: start empty so users enable on demand
+ return []string{}
}
- after, err := OptionalParam[string](args, "after")
- if err != nil {
- return CursorPaginationParams{}, err
+ if len(enabledTools) > 0 {
+ // When specific tools are requested but no toolsets, don't use default toolsets
+ // This matches the original behavior: --tools=X alone registers only X
+ return []string{}
}
- return CursorPaginationParams{
- PerPage: perPage,
- After: after,
- }, nil
-}
-type CursorPaginationParams struct {
- PerPage int
- After string
+ // nil means "use defaults" in WithToolsets
+ return nil
}
-// ToGraphQLParams converts cursor pagination parameters to GraphQL-specific parameters.
-func (p CursorPaginationParams) ToGraphQLParams() (*GraphQLPaginationParams, error) {
- if p.PerPage > 100 {
- return nil, fmt.Errorf("perPage value %d exceeds maximum of 100", p.PerPage)
- }
- if p.PerPage < 0 {
- return nil, fmt.Errorf("perPage value %d cannot be negative", p.PerPage)
+func addGitHubAPIErrorToContext(next mcp.MethodHandler) mcp.MethodHandler {
+ return func(ctx context.Context, method string, req mcp.Request) (result mcp.Result, err error) {
+ // Ensure the context is cleared of any previous errors
+ // as context isn't propagated through middleware
+ ctx = gherrors.ContextWithGitHubErrors(ctx)
+ return next(ctx, method, req)
}
- first := int32(p.PerPage)
+}
- var after *string
- if p.After != "" {
- after = &p.After
+// NewServer creates a new GitHub MCP server with the specified GH client and logger.
+func NewServer(version string, opts *mcp.ServerOptions) *mcp.Server {
+ if opts == nil {
+ opts = &mcp.ServerOptions{}
}
- return &GraphQLPaginationParams{
- First: &first,
- After: after,
- }, nil
-}
+ // Create a new MCP server
+ s := mcp.NewServer(&mcp.Implementation{
+ Name: "github-mcp-server",
+ Title: "GitHub MCP Server",
+ Version: version,
+ Icons: octicons.Icons("mark-github"),
+ }, opts)
-type GraphQLPaginationParams struct {
- First *int32
- After *string
+ return s
}
-// ToGraphQLParams converts REST API pagination parameters to GraphQL-specific parameters.
-// This converts page/perPage to first parameter for GraphQL queries.
-// If After is provided, it takes precedence over page-based pagination.
-func (p PaginationParams) ToGraphQLParams() (*GraphQLPaginationParams, error) {
- // Convert to CursorPaginationParams and delegate to avoid duplication
- cursor := CursorPaginationParams{
- PerPage: p.PerPage,
- After: p.After,
+func CompletionsHandler(getClient GetClientFn) func(ctx context.Context, req *mcp.CompleteRequest) (*mcp.CompleteResult, error) {
+ return func(ctx context.Context, req *mcp.CompleteRequest) (*mcp.CompleteResult, error) {
+ switch req.Params.Ref.Type {
+ case "ref/resource":
+ if strings.HasPrefix(req.Params.Ref.URI, "repo://") {
+ return RepositoryResourceCompletionHandler(getClient)(ctx, req)
+ }
+ return nil, fmt.Errorf("unsupported resource URI: %s", req.Params.Ref.URI)
+ case "ref/prompt":
+ return nil, nil
+ default:
+ return nil, fmt.Errorf("unsupported ref type: %s", req.Params.Ref.Type)
+ }
}
- return cursor.ToGraphQLParams()
}
func MarshalledTextResult(v any) *mcp.CallToolResult {
diff --git a/pkg/github/server_test.go b/pkg/github/server_test.go
index a59cd9a93..f21752b27 100644
--- a/pkg/github/server_test.go
+++ b/pkg/github/server_test.go
@@ -12,15 +12,16 @@ import (
"github.com/github/github-mcp-server/pkg/lockdown"
"github.com/github/github-mcp-server/pkg/raw"
"github.com/github/github-mcp-server/pkg/translations"
- "github.com/google/go-github/v79/github"
+ gogithub "github.com/google/go-github/v79/github"
"github.com/shurcooL/githubv4"
"github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
)
// stubDeps is a test helper that implements ToolDependencies with configurable behavior.
// Use this when you need to test error paths or when you need closure-based client creation.
type stubDeps struct {
- clientFn func(context.Context) (*github.Client, error)
+ clientFn func(context.Context) (*gogithub.Client, error)
gqlClientFn func(context.Context) (*githubv4.Client, error)
rawClientFn func(context.Context) (*raw.Client, error)
@@ -30,7 +31,7 @@ type stubDeps struct {
contentWindowSize int
}
-func (s stubDeps) GetClient(ctx context.Context) (*github.Client, error) {
+func (s stubDeps) GetClient(ctx context.Context) (*gogithub.Client, error) {
if s.clientFn != nil {
return s.clientFn(ctx)
}
@@ -51,20 +52,23 @@ func (s stubDeps) GetRawClient(ctx context.Context) (*raw.Client, error) {
return nil, nil
}
-func (s stubDeps) GetRepoAccessCache() *lockdown.RepoAccessCache { return s.repoAccessCache }
-func (s stubDeps) GetT() translations.TranslationHelperFunc { return s.t }
-func (s stubDeps) GetFlags() FeatureFlags { return s.flags }
-func (s stubDeps) GetContentWindowSize() int { return s.contentWindowSize }
+func (s stubDeps) GetRepoAccessCache(_ context.Context) (*lockdown.RepoAccessCache, error) {
+ return s.repoAccessCache, nil
+}
+func (s stubDeps) GetT() translations.TranslationHelperFunc { return s.t }
+func (s stubDeps) GetFlags(_ context.Context) FeatureFlags { return s.flags }
+func (s stubDeps) GetContentWindowSize() int { return s.contentWindowSize }
+func (s stubDeps) IsFeatureEnabled(_ context.Context, _ string) bool { return false }
// Helper functions to create stub client functions for error testing
-func stubClientFnFromHTTP(httpClient *http.Client) func(context.Context) (*github.Client, error) {
- return func(_ context.Context) (*github.Client, error) {
- return github.NewClient(httpClient), nil
+func stubClientFnFromHTTP(httpClient *http.Client) func(context.Context) (*gogithub.Client, error) {
+ return func(_ context.Context) (*gogithub.Client, error) {
+ return gogithub.NewClient(httpClient), nil
}
}
-func stubClientFnErr(errMsg string) func(context.Context) (*github.Client, error) {
- return func(_ context.Context) (*github.Client, error) {
+func stubClientFnErr(errMsg string) func(context.Context) (*gogithub.Client, error) {
+ return func(_ context.Context) (*gogithub.Client, error) {
return nil, errors.New(errMsg)
}
}
@@ -83,12 +87,13 @@ func stubRepoAccessCache(client *githubv4.Client, ttl time.Duration) *lockdown.R
func stubFeatureFlags(enabledFlags map[string]bool) FeatureFlags {
return FeatureFlags{
LockdownMode: enabledFlags["lockdown-mode"],
+ InsidersMode: enabledFlags["insiders-mode"],
}
}
func badRequestHandler(msg string) http.HandlerFunc {
return func(w http.ResponseWriter, _ *http.Request) {
- structuredErrorResponse := github.ErrorResponse{
+ structuredErrorResponse := gogithub.ErrorResponse{
Message: msg,
}
@@ -101,496 +106,116 @@ func badRequestHandler(msg string) http.HandlerFunc {
}
}
-func Test_IsAcceptedError(t *testing.T) {
- tests := []struct {
- name string
- err error
- expectAccepted bool
- }{
- {
- name: "github AcceptedError",
- err: &github.AcceptedError{},
- expectAccepted: true,
- },
- {
- name: "regular error",
- err: fmt.Errorf("some other error"),
- expectAccepted: false,
- },
- {
- name: "nil error",
- err: nil,
- expectAccepted: false,
- },
- {
- name: "wrapped AcceptedError",
- err: fmt.Errorf("wrapped: %w", &github.AcceptedError{}),
- expectAccepted: true,
- },
- }
-
- for _, tc := range tests {
- t.Run(tc.name, func(t *testing.T) {
- result := isAcceptedError(tc.err)
- assert.Equal(t, tc.expectAccepted, result)
- })
- }
-}
+// TestNewMCPServer_CreatesSuccessfully verifies that the server can be created
+// with the deps injection middleware properly configured.
+func TestNewMCPServer_CreatesSuccessfully(t *testing.T) {
+ t.Parallel()
-func Test_RequiredStringParam(t *testing.T) {
- tests := []struct {
- name string
- params map[string]interface{}
- paramName string
- expected string
- expectError bool
- }{
- {
- name: "valid string parameter",
- params: map[string]interface{}{"name": "test-value"},
- paramName: "name",
- expected: "test-value",
- expectError: false,
- },
- {
- name: "missing parameter",
- params: map[string]interface{}{},
- paramName: "name",
- expected: "",
- expectError: true,
- },
- {
- name: "empty string parameter",
- params: map[string]interface{}{"name": ""},
- paramName: "name",
- expected: "",
- expectError: true,
- },
- {
- name: "wrong type parameter",
- params: map[string]interface{}{"name": 123},
- paramName: "name",
- expected: "",
- expectError: true,
- },
+ // Create a minimal server configuration
+ cfg := MCPServerConfig{
+ Version: "test",
+ Host: "", // defaults to github.com
+ Token: "test-token",
+ EnabledToolsets: []string{"context"},
+ ReadOnly: false,
+ Translator: translations.NullTranslationHelper,
+ ContentWindowSize: 5000,
+ LockdownMode: false,
+ InsidersMode: false,
}
- for _, tc := range tests {
- t.Run(tc.name, func(t *testing.T) {
- result, err := RequiredParam[string](tc.params, tc.paramName)
+ deps := stubDeps{}
- if tc.expectError {
- assert.Error(t, err)
- } else {
- assert.NoError(t, err)
- assert.Equal(t, tc.expected, result)
- }
- })
- }
-}
+ // Build inventory
+ inv, err := NewInventory(cfg.Translator).
+ WithDeprecatedAliases(DeprecatedToolAliases).
+ WithToolsets(cfg.EnabledToolsets).
+ Build()
-func Test_OptionalStringParam(t *testing.T) {
- tests := []struct {
- name string
- params map[string]interface{}
- paramName string
- expected string
- expectError bool
- }{
- {
- name: "valid string parameter",
- params: map[string]interface{}{"name": "test-value"},
- paramName: "name",
- expected: "test-value",
- expectError: false,
- },
- {
- name: "missing parameter",
- params: map[string]interface{}{},
- paramName: "name",
- expected: "",
- expectError: false,
- },
- {
- name: "empty string parameter",
- params: map[string]interface{}{"name": ""},
- paramName: "name",
- expected: "",
- expectError: false,
- },
- {
- name: "wrong type parameter",
- params: map[string]interface{}{"name": 123},
- paramName: "name",
- expected: "",
- expectError: true,
- },
- }
+ require.NoError(t, err, "expected inventory build to succeed")
- for _, tc := range tests {
- t.Run(tc.name, func(t *testing.T) {
- result, err := OptionalParam[string](tc.params, tc.paramName)
+ // Create the server
+ server, err := NewMCPServer(context.Background(), &cfg, deps, inv)
+ require.NoError(t, err, "expected server creation to succeed")
+ require.NotNil(t, server, "expected server to be non-nil")
- if tc.expectError {
- assert.Error(t, err)
- } else {
- assert.NoError(t, err)
- assert.Equal(t, tc.expected, result)
- }
- })
- }
+ // The fact that the server was created successfully indicates that:
+ // 1. The deps injection middleware is properly added
+ // 2. Tools can be registered without panicking
+ //
+ // If the middleware wasn't properly added, tool calls would panic with
+ // "ToolDependencies not found in context" when executed.
+ //
+ // The actual middleware functionality and tool execution with ContextWithDeps
+ // is already tested in pkg/github/*_test.go.
}
-func Test_RequiredInt(t *testing.T) {
- tests := []struct {
- name string
- params map[string]interface{}
- paramName string
- expected int
- expectError bool
- }{
- {
- name: "valid number parameter",
- params: map[string]interface{}{"count": float64(42)},
- paramName: "count",
- expected: 42,
- expectError: false,
- },
- {
- name: "missing parameter",
- params: map[string]interface{}{},
- paramName: "count",
- expected: 0,
- expectError: true,
- },
- {
- name: "wrong type parameter",
- params: map[string]interface{}{"count": "not-a-number"},
- paramName: "count",
- expected: 0,
- expectError: true,
- },
- }
-
- for _, tc := range tests {
- t.Run(tc.name, func(t *testing.T) {
- result, err := RequiredInt(tc.params, tc.paramName)
+// TestResolveEnabledToolsets verifies the toolset resolution logic.
+func TestResolveEnabledToolsets(t *testing.T) {
+ t.Parallel()
- if tc.expectError {
- assert.Error(t, err)
- } else {
- assert.NoError(t, err)
- assert.Equal(t, tc.expected, result)
- }
- })
- }
-}
-func Test_OptionalIntParam(t *testing.T) {
tests := []struct {
- name string
- params map[string]interface{}
- paramName string
- expected int
- expectError bool
- }{
- {
- name: "valid number parameter",
- params: map[string]interface{}{"count": float64(42)},
- paramName: "count",
- expected: 42,
- expectError: false,
- },
- {
- name: "missing parameter",
- params: map[string]interface{}{},
- paramName: "count",
- expected: 0,
- expectError: false,
- },
- {
- name: "zero value",
- params: map[string]interface{}{"count": float64(0)},
- paramName: "count",
- expected: 0,
- expectError: false,
- },
- {
- name: "wrong type parameter",
- params: map[string]interface{}{"count": "not-a-number"},
- paramName: "count",
- expected: 0,
- expectError: true,
- },
- }
-
- for _, tc := range tests {
- t.Run(tc.name, func(t *testing.T) {
- result, err := OptionalIntParam(tc.params, tc.paramName)
-
- if tc.expectError {
- assert.Error(t, err)
- } else {
- assert.NoError(t, err)
- assert.Equal(t, tc.expected, result)
- }
- })
- }
-}
-
-func Test_OptionalNumberParamWithDefault(t *testing.T) {
- tests := []struct {
- name string
- params map[string]interface{}
- paramName string
- defaultVal int
- expected int
- expectError bool
- }{
- {
- name: "valid number parameter",
- params: map[string]interface{}{"count": float64(42)},
- paramName: "count",
- defaultVal: 10,
- expected: 42,
- expectError: false,
- },
- {
- name: "missing parameter",
- params: map[string]interface{}{},
- paramName: "count",
- defaultVal: 10,
- expected: 10,
- expectError: false,
- },
- {
- name: "zero value",
- params: map[string]interface{}{"count": float64(0)},
- paramName: "count",
- defaultVal: 10,
- expected: 10,
- expectError: false,
- },
- {
- name: "wrong type parameter",
- params: map[string]interface{}{"count": "not-a-number"},
- paramName: "count",
- defaultVal: 10,
- expected: 0,
- expectError: true,
- },
- }
-
- for _, tc := range tests {
- t.Run(tc.name, func(t *testing.T) {
- result, err := OptionalIntParamWithDefault(tc.params, tc.paramName, tc.defaultVal)
-
- if tc.expectError {
- assert.Error(t, err)
- } else {
- assert.NoError(t, err)
- assert.Equal(t, tc.expected, result)
- }
- })
- }
-}
-
-func Test_OptionalBooleanParam(t *testing.T) {
- tests := []struct {
- name string
- params map[string]interface{}
- paramName string
- expected bool
- expectError bool
- }{
- {
- name: "true value",
- params: map[string]interface{}{"flag": true},
- paramName: "flag",
- expected: true,
- expectError: false,
- },
- {
- name: "false value",
- params: map[string]interface{}{"flag": false},
- paramName: "flag",
- expected: false,
- expectError: false,
- },
- {
- name: "missing parameter",
- params: map[string]interface{}{},
- paramName: "flag",
- expected: false,
- expectError: false,
- },
- {
- name: "wrong type parameter",
- params: map[string]interface{}{"flag": "not-a-boolean"},
- paramName: "flag",
- expected: false,
- expectError: true,
- },
- }
-
- for _, tc := range tests {
- t.Run(tc.name, func(t *testing.T) {
- result, err := OptionalParam[bool](tc.params, tc.paramName)
-
- if tc.expectError {
- assert.Error(t, err)
- } else {
- assert.NoError(t, err)
- assert.Equal(t, tc.expected, result)
- }
- })
- }
-}
-
-func TestOptionalStringArrayParam(t *testing.T) {
- tests := []struct {
- name string
- params map[string]interface{}
- paramName string
- expected []string
- expectError bool
- }{
- {
- name: "parameter not in request",
- params: map[string]any{},
- paramName: "flag",
- expected: []string{},
- expectError: false,
- },
- {
- name: "valid any array parameter",
- params: map[string]any{
- "flag": []any{"v1", "v2"},
- },
- paramName: "flag",
- expected: []string{"v1", "v2"},
- expectError: false,
- },
- {
- name: "valid string array parameter",
- params: map[string]any{
- "flag": []string{"v1", "v2"},
- },
- paramName: "flag",
- expected: []string{"v1", "v2"},
- expectError: false,
- },
- {
- name: "wrong type parameter",
- params: map[string]any{
- "flag": 1,
- },
- paramName: "flag",
- expected: []string{},
- expectError: true,
- },
- {
- name: "wrong slice type parameter",
- params: map[string]any{
- "flag": []any{"foo", 2},
- },
- paramName: "flag",
- expected: []string{},
- expectError: true,
- },
- }
-
- for _, tc := range tests {
- t.Run(tc.name, func(t *testing.T) {
- result, err := OptionalStringArrayParam(tc.params, tc.paramName)
-
- if tc.expectError {
- assert.Error(t, err)
- } else {
- assert.NoError(t, err)
- assert.Equal(t, tc.expected, result)
- }
- })
- }
-}
-
-func TestOptionalPaginationParams(t *testing.T) {
- tests := []struct {
- name string
- params map[string]any
- expected PaginationParams
- expectError bool
+ name string
+ cfg MCPServerConfig
+ expectedResult []string
}{
{
- name: "no pagination parameters, default values",
- params: map[string]any{},
- expected: PaginationParams{
- Page: 1,
- PerPage: 30,
+ name: "nil toolsets without dynamic mode and no tools - use defaults",
+ cfg: MCPServerConfig{
+ EnabledToolsets: nil,
+ DynamicToolsets: false,
+ EnabledTools: nil,
},
- expectError: false,
+ expectedResult: nil, // nil means "use defaults"
},
{
- name: "page parameter, default perPage",
- params: map[string]any{
- "page": float64(2),
+ name: "nil toolsets with dynamic mode - start empty",
+ cfg: MCPServerConfig{
+ EnabledToolsets: nil,
+ DynamicToolsets: true,
+ EnabledTools: nil,
},
- expected: PaginationParams{
- Page: 2,
- PerPage: 30,
- },
- expectError: false,
+ expectedResult: []string{}, // empty slice means no toolsets
},
{
- name: "perPage parameter, default page",
- params: map[string]any{
- "perPage": float64(50),
- },
- expected: PaginationParams{
- Page: 1,
- PerPage: 50,
+ name: "explicit toolsets",
+ cfg: MCPServerConfig{
+ EnabledToolsets: []string{"repos", "issues"},
+ DynamicToolsets: false,
},
- expectError: false,
+ expectedResult: []string{"repos", "issues"},
},
{
- name: "page and perPage parameters",
- params: map[string]any{
- "page": float64(2),
- "perPage": float64(50),
+ name: "empty toolsets - disable all",
+ cfg: MCPServerConfig{
+ EnabledToolsets: []string{},
+ DynamicToolsets: false,
},
- expected: PaginationParams{
- Page: 2,
- PerPage: 50,
- },
- expectError: false,
+ expectedResult: []string{}, // empty slice means no toolsets
},
{
- name: "invalid page parameter",
- params: map[string]any{
- "page": "not-a-number",
+ name: "specific tools without toolsets - no default toolsets",
+ cfg: MCPServerConfig{
+ EnabledToolsets: nil,
+ DynamicToolsets: false,
+ EnabledTools: []string{"get_me"},
},
- expected: PaginationParams{},
- expectError: true,
+ expectedResult: []string{}, // empty slice when tools specified but no toolsets
},
{
- name: "invalid perPage parameter",
- params: map[string]any{
- "perPage": "not-a-number",
+ name: "dynamic mode with explicit toolsets removes all and default",
+ cfg: MCPServerConfig{
+ EnabledToolsets: []string{"all", "repos"},
+ DynamicToolsets: true,
},
- expected: PaginationParams{},
- expectError: true,
+ expectedResult: []string{"repos"}, // "all" is removed in dynamic mode
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
- result, err := OptionalPaginationParams(tc.params)
-
- if tc.expectError {
- assert.Error(t, err)
- } else {
- assert.NoError(t, err)
- assert.Equal(t, tc.expected, result)
- }
+ result := ResolvedEnabledToolsets(tc.cfg.DynamicToolsets, tc.cfg.EnabledToolsets, tc.cfg.EnabledTools)
+ assert.Equal(t, tc.expectedResult, result)
})
}
}
diff --git a/pkg/github/tools.go b/pkg/github/tools.go
index 62b67af6f..676976140 100644
--- a/pkg/github/tools.go
+++ b/pkg/github/tools.go
@@ -28,10 +28,11 @@ var (
Icon: "check-circle",
}
ToolsetMetadataContext = inventory.ToolsetMetadata{
- ID: "context",
- Description: "Tools that provide context about the current user and GitHub context you are operating in",
- Default: true,
- Icon: "person",
+ ID: "context",
+ Description: "Tools that provide context about the current user and GitHub context you are operating in",
+ Default: true,
+ Icon: "person",
+ InstructionsFunc: generateContextToolsetInstructions,
}
ToolsetMetadataRepos = inventory.ToolsetMetadata{
ID: "repos",
@@ -45,16 +46,18 @@ var (
Icon: "git-branch",
}
ToolsetMetadataIssues = inventory.ToolsetMetadata{
- ID: "issues",
- Description: "GitHub Issues related tools",
- Default: true,
- Icon: "issue-opened",
+ ID: "issues",
+ Description: "GitHub Issues related tools",
+ Default: true,
+ Icon: "issue-opened",
+ InstructionsFunc: generateIssuesToolsetInstructions,
}
ToolsetMetadataPullRequests = inventory.ToolsetMetadata{
- ID: "pull_requests",
- Description: "GitHub Pull Request related tools",
- Default: true,
- Icon: "git-pull-request",
+ ID: "pull_requests",
+ Description: "GitHub Pull Request related tools",
+ Default: true,
+ Icon: "git-pull-request",
+ InstructionsFunc: generatePullRequestsToolsetInstructions,
}
ToolsetMetadataUsers = inventory.ToolsetMetadata{
ID: "users",
@@ -92,15 +95,11 @@ var (
Description: "GitHub Notifications related tools",
Icon: "bell",
}
- ToolsetMetadataExperiments = inventory.ToolsetMetadata{
- ID: "experiments",
- Description: "Experimental features that are not considered stable yet",
- Icon: "beaker",
- }
ToolsetMetadataDiscussions = inventory.ToolsetMetadata{
- ID: "discussions",
- Description: "GitHub Discussions related tools",
- Icon: "comment-discussion",
+ ID: "discussions",
+ Description: "GitHub Discussions related tools",
+ Icon: "comment-discussion",
+ InstructionsFunc: generateDiscussionsToolsetInstructions,
}
ToolsetMetadataGists = inventory.ToolsetMetadata{
ID: "gists",
@@ -113,9 +112,10 @@ var (
Icon: "shield",
}
ToolsetMetadataProjects = inventory.ToolsetMetadata{
- ID: "projects",
- Description: "GitHub Projects related tools",
- Icon: "project",
+ ID: "projects",
+ Description: "GitHub Projects related tools",
+ Icon: "project",
+ InstructionsFunc: generateProjectsToolsetInstructions,
}
ToolsetMetadataStargazers = inventory.ToolsetMetadata{
ID: "stargazers",
@@ -213,6 +213,7 @@ func AllTools(t translations.TranslationHelperFunc) []inventory.ServerTool {
RequestCopilotReview(t),
PullRequestReviewWrite(t),
AddCommentToPendingReview(t),
+ AddReplyToPullRequestComment(t),
// Code security tools
GetCodeScanningAlert(t),
@@ -284,6 +285,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),
@@ -309,7 +315,8 @@ func ToStringPtr(s string) *string {
// GenerateToolsetsHelp generates the help text for the toolsets flag
func GenerateToolsetsHelp() string {
// Get toolset group to derive defaults and available toolsets
- r := NewInventory(stubTranslator).Build()
+ // Build() can only fail if WithTools specifies invalid tools - not used here
+ r, _ := NewInventory(stubTranslator).Build()
// Format default tools from metadata using strings.Builder
var defaultBuf strings.Builder
@@ -391,7 +398,8 @@ func AddDefaultToolset(result []string) []string {
result = RemoveToolset(result, string(ToolsetMetadataDefault.ID))
// Get default toolset IDs from the Inventory
- r := NewInventory(stubTranslator).Build()
+ // Build() can only fail if WithTools specifies invalid tools - not used here
+ r, _ := NewInventory(stubTranslator).Build()
for _, id := range r.DefaultToolsetIDs() {
if !seen[string(id)] {
result = append(result, string(id))
@@ -443,7 +451,8 @@ func CleanTools(toolNames []string) []string {
// GetDefaultToolsetIDs returns the IDs of toolsets marked as Default.
// This is a convenience function that builds an inventory to determine defaults.
func GetDefaultToolsetIDs() []string {
- r := NewInventory(stubTranslator).Build()
+ // Build() can only fail if WithTools specifies invalid tools - not used here
+ r, _ := NewInventory(stubTranslator).Build()
ids := r.DefaultToolsetIDs()
result := make([]string, len(ids))
for i, id := range ids {
diff --git a/pkg/github/toolset_icons_test.go b/pkg/github/toolset_icons_test.go
index fd9cec462..7cfe4bef7 100644
--- a/pkg/github/toolset_icons_test.go
+++ b/pkg/github/toolset_icons_test.go
@@ -13,7 +13,8 @@ import (
// This prevents broken icon references from being merged.
func TestAllToolsetIconsExist(t *testing.T) {
// Get all available toolsets from the inventory
- inv := NewInventory(stubTranslator).Build()
+ inv, err := NewInventory(stubTranslator).Build()
+ require.NoError(t, err)
toolsets := inv.AvailableToolsets()
// Also test remote-only toolsets
@@ -72,7 +73,8 @@ func TestToolsetMetadataHasIcons(t *testing.T) {
"default": true, // Meta-toolset
}
- inv := NewInventory(stubTranslator).Build()
+ inv, err := NewInventory(stubTranslator).Build()
+ require.NoError(t, err)
toolsets := inv.AvailableToolsets()
for _, ts := range toolsets {
diff --git a/pkg/github/instructions.go b/pkg/github/toolset_instructions.go
similarity index 65%
rename from pkg/github/instructions.go
rename to pkg/github/toolset_instructions.go
index 3a5fb54bb..bf2388a3d 100644
--- a/pkg/github/instructions.go
+++ b/pkg/github/toolset_instructions.go
@@ -1,75 +1,41 @@
package github
-import (
- "os"
- "slices"
- "strings"
-)
-
-// GenerateInstructions creates server instructions based on enabled toolsets
-func GenerateInstructions(enabledToolsets []string) string {
- // For testing - add a flag to disable instructions
- if os.Getenv("DISABLE_INSTRUCTIONS") == "true" {
- return "" // Baseline mode
- }
-
- var instructions []string
+import "github.com/github/github-mcp-server/pkg/inventory"
- // Core instruction - always included if context toolset enabled
- if slices.Contains(enabledToolsets, "context") {
- instructions = append(instructions, "Always call 'get_me' first to understand current user permissions and context.")
- }
-
- // Individual toolset instructions
- for _, toolset := range enabledToolsets {
- if inst := getToolsetInstructions(toolset, enabledToolsets); inst != "" {
- instructions = append(instructions, inst)
- }
- }
+// Toolset instruction functions - these generate context-aware instructions for each toolset.
+// They are called during inventory build to generate server instructions.
- // Base instruction with context management
- baseInstruction := `The GitHub MCP Server provides tools to interact with GitHub platform.
-
-Tool selection guidance:
- 1. Use 'list_*' tools for broad, simple retrieval and pagination of all items of a type (e.g., all issues, all PRs, all branches) with basic filtering.
- 2. Use 'search_*' tools for targeted queries with specific criteria, keywords, or complex filters (e.g., issues with certain text, PRs by author, code containing functions).
-
-Context management:
- 1. Use pagination whenever possible with batches of 5-10 items.
- 2. Use minimal_output parameter set to true if the full information is not needed to accomplish a task.
-
-Tool usage guidance:
- 1. For 'search_*' tools: Use separate 'sort' and 'order' parameters if available for sorting results - do not include 'sort:' syntax in query strings. Query strings should contain only search criteria (e.g., 'org:google language:python'), not sorting instructions.`
+func generateContextToolsetInstructions(_ *inventory.Inventory) string {
+ return "Always call 'get_me' first to understand current user permissions and context."
+}
- allInstructions := []string{baseInstruction}
- allInstructions = append(allInstructions, instructions...)
+func generateIssuesToolsetInstructions(_ *inventory.Inventory) string {
+ return `## Issues
- return strings.Join(allInstructions, " ")
+Check 'list_issue_types' first for organizations to use proper issue types. Use 'search_issues' before creating new issues to avoid duplicates. Always set 'state_reason' when closing issues.`
}
-// getToolsetInstructions returns specific instructions for individual toolsets
-func getToolsetInstructions(toolset string, enabledToolsets []string) string {
- switch toolset {
- case "pull_requests":
- pullRequestInstructions := `## Pull Requests
+func generatePullRequestsToolsetInstructions(inv *inventory.Inventory) string {
+ instructions := `## Pull Requests
PR review workflow: Always use 'pull_request_review_write' with method 'create' to create a pending review, then 'add_comment_to_pending_review' to add comments, and finally 'pull_request_review_write' with method 'submit_pending' to submit the review for complex reviews with line-specific comments.`
- if slices.Contains(enabledToolsets, "repos") {
- pullRequestInstructions += `
+
+ if inv.HasToolset("repos") {
+ instructions += `
Before creating a pull request, search for pull request templates in the repository. Template files are called pull_request_template.md or they're located in '.github/PULL_REQUEST_TEMPLATE' directory. Use the template content to structure the PR description and then call create_pull_request tool.`
- }
- return pullRequestInstructions
- case "issues":
- return `## Issues
+ }
+ return instructions
+}
+
+func generateDiscussionsToolsetInstructions(_ *inventory.Inventory) string {
+ return `## Discussions
-Check 'list_issue_types' first for organizations to use proper issue types. Use 'search_issues' before creating new issues to avoid duplicates. Always set 'state_reason' when closing issues.`
- case "discussions":
- return `## Discussions
-
Use 'list_discussion_categories' to understand available categories before creating discussions. Filter by category for better organization.`
- case "projects":
- return `## Projects
+}
+
+func generateProjectsToolsetInstructions(_ *inventory.Inventory) string {
+ return `## Projects
Workflow: 1) list_project_fields (get field IDs), 2) list_project_items (with pagination), 3) optional updates.
@@ -137,7 +103,4 @@ Common Qualifier Glossary (items):
Never:
- Infer field IDs; fetch via list_project_fields.
- Drop 'fields' param on subsequent pages if field values are needed.`
- default:
- return ""
- }
}
diff --git a/pkg/http/handler.go b/pkg/http/handler.go
new file mode 100644
index 000000000..df0b819fc
--- /dev/null
+++ b/pkg/http/handler.go
@@ -0,0 +1,287 @@
+package http
+
+import (
+ "context"
+ "log/slog"
+ "net/http"
+
+ ghcontext "github.com/github/github-mcp-server/pkg/context"
+ "github.com/github/github-mcp-server/pkg/github"
+ "github.com/github/github-mcp-server/pkg/http/middleware"
+ "github.com/github/github-mcp-server/pkg/http/oauth"
+ "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-chi/chi/v5"
+ "github.com/modelcontextprotocol/go-sdk/mcp"
+)
+
+type InventoryFactoryFunc func(r *http.Request) (*inventory.Inventory, error)
+type GitHubMCPServerFactoryFunc func(r *http.Request, deps github.ToolDependencies, inventory *inventory.Inventory, cfg *github.MCPServerConfig) (*mcp.Server, error)
+
+type Handler struct {
+ ctx context.Context
+ config *ServerConfig
+ deps github.ToolDependencies
+ logger *slog.Logger
+ apiHosts utils.APIHostResolver
+ t translations.TranslationHelperFunc
+ githubMcpServerFactory GitHubMCPServerFactoryFunc
+ inventoryFactoryFunc InventoryFactoryFunc
+ oauthCfg *oauth.Config
+ scopeFetcher scopes.FetcherInterface
+}
+
+type HandlerOptions struct {
+ GitHubMcpServerFactory GitHubMCPServerFactoryFunc
+ InventoryFactory InventoryFactoryFunc
+ OAuthConfig *oauth.Config
+ ScopeFetcher scopes.FetcherInterface
+ FeatureChecker inventory.FeatureFlagChecker
+}
+
+type HandlerOption func(*HandlerOptions)
+
+func WithScopeFetcher(f scopes.FetcherInterface) HandlerOption {
+ return func(o *HandlerOptions) {
+ o.ScopeFetcher = f
+ }
+}
+
+func WithGitHubMCPServerFactory(f GitHubMCPServerFactoryFunc) HandlerOption {
+ return func(o *HandlerOptions) {
+ o.GitHubMcpServerFactory = f
+ }
+}
+
+func WithInventoryFactory(f InventoryFactoryFunc) HandlerOption {
+ return func(o *HandlerOptions) {
+ o.InventoryFactory = f
+ }
+}
+
+func WithOAuthConfig(cfg *oauth.Config) HandlerOption {
+ return func(o *HandlerOptions) {
+ o.OAuthConfig = cfg
+ }
+}
+
+func WithFeatureChecker(checker inventory.FeatureFlagChecker) HandlerOption {
+ return func(o *HandlerOptions) {
+ o.FeatureChecker = checker
+ }
+}
+
+func NewHTTPMcpHandler(
+ ctx context.Context,
+ cfg *ServerConfig,
+ deps github.ToolDependencies,
+ t translations.TranslationHelperFunc,
+ logger *slog.Logger,
+ apiHost utils.APIHostResolver,
+ options ...HandlerOption) *Handler {
+ opts := &HandlerOptions{}
+ for _, o := range options {
+ o(opts)
+ }
+
+ githubMcpServerFactory := opts.GitHubMcpServerFactory
+ if githubMcpServerFactory == nil {
+ githubMcpServerFactory = DefaultGitHubMCPServerFactory
+ }
+
+ scopeFetcher := opts.ScopeFetcher
+ if scopeFetcher == nil {
+ scopeFetcher = scopes.NewFetcher(apiHost, scopes.FetcherOptions{})
+ }
+
+ inventoryFactory := opts.InventoryFactory
+ if inventoryFactory == nil {
+ inventoryFactory = DefaultInventoryFactory(cfg, t, opts.FeatureChecker, scopeFetcher)
+ }
+
+ return &Handler{
+ ctx: ctx,
+ config: cfg,
+ deps: deps,
+ logger: logger,
+ apiHosts: apiHost,
+ t: t,
+ githubMcpServerFactory: githubMcpServerFactory,
+ inventoryFactoryFunc: inventoryFactory,
+ oauthCfg: opts.OAuthConfig,
+ scopeFetcher: scopeFetcher,
+ }
+}
+
+func (h *Handler) RegisterMiddleware(r chi.Router) {
+ r.Use(
+ middleware.ExtractUserToken(h.oauthCfg),
+ middleware.WithRequestConfig,
+ middleware.WithMCPParse(),
+ middleware.WithPATScopes(h.logger, h.scopeFetcher),
+ )
+
+ if h.config.ScopeChallenge {
+ r.Use(middleware.WithScopeChallenge(h.oauthCfg, h.scopeFetcher))
+ }
+}
+
+// RegisterRoutes registers the routes for the MCP server
+// URL-based values take precedence over header-based values
+func (h *Handler) RegisterRoutes(r chi.Router) {
+ // Base routes
+ r.Mount("/", h)
+ r.With(withReadonly).Mount("/readonly", h)
+ r.With(withInsiders).Mount("/insiders", h)
+ r.With(withReadonly, withInsiders).Mount("/readonly/insiders", h)
+
+ // Toolset routes
+ r.With(withToolset).Mount("/x/{toolset}", h)
+ r.With(withToolset, withReadonly).Mount("/x/{toolset}/readonly", h)
+ r.With(withToolset, withInsiders).Mount("/x/{toolset}/insiders", h)
+ r.With(withToolset, withReadonly, withInsiders).Mount("/x/{toolset}/readonly/insiders", h)
+}
+
+// withReadonly is middleware that sets readonly mode in the request context
+func withReadonly(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ ctx := ghcontext.WithReadonly(r.Context(), true)
+ next.ServeHTTP(w, r.WithContext(ctx))
+ })
+}
+
+// withToolset is middleware that extracts the toolset from the URL and sets it in the request context
+func withToolset(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ toolset := chi.URLParam(r, "toolset")
+ ctx := ghcontext.WithToolsets(r.Context(), []string{toolset})
+ next.ServeHTTP(w, r.WithContext(ctx))
+ })
+}
+
+// withInsiders is middleware that sets insiders mode in the request context
+func withInsiders(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ ctx := ghcontext.WithInsidersMode(r.Context(), true)
+ next.ServeHTTP(w, r.WithContext(ctx))
+ })
+}
+
+func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ inv, err := h.inventoryFactoryFunc(r)
+ if err != nil {
+ w.WriteHeader(http.StatusInternalServerError)
+ return
+ }
+
+ invToUse := inv
+ if methodInfo, ok := ghcontext.MCPMethod(r.Context()); ok && methodInfo != nil {
+ invToUse = inv.ForMCPRequest(methodInfo.Method, methodInfo.ItemName)
+ }
+
+ ghServer, err := h.githubMcpServerFactory(r, h.deps, invToUse, &github.MCPServerConfig{
+ Version: h.config.Version,
+ Translator: h.t,
+ ContentWindowSize: h.config.ContentWindowSize,
+ Logger: h.logger,
+ RepoAccessTTL: h.config.RepoAccessCacheTTL,
+ // Explicitly set empty capabilities. inv.ForMCPRequest currently returns nothing for Initialize.
+ ServerOptions: []github.MCPServerOption{
+ func(so *mcp.ServerOptions) {
+ so.Capabilities = &mcp.ServerCapabilities{
+ Tools: &mcp.ToolCapabilities{},
+ Resources: &mcp.ResourceCapabilities{},
+ Prompts: &mcp.PromptCapabilities{},
+ }
+ },
+ },
+ })
+
+ if err != nil {
+ w.WriteHeader(http.StatusInternalServerError)
+ return
+ }
+
+ mcpHandler := mcp.NewStreamableHTTPHandler(func(_ *http.Request) *mcp.Server {
+ return ghServer
+ }, &mcp.StreamableHTTPOptions{
+ Stateless: true,
+ })
+
+ mcpHandler.ServeHTTP(w, r)
+}
+
+func DefaultGitHubMCPServerFactory(r *http.Request, deps github.ToolDependencies, inventory *inventory.Inventory, cfg *github.MCPServerConfig) (*mcp.Server, error) {
+ return github.NewMCPServer(r.Context(), cfg, deps, inventory)
+}
+
+// DefaultInventoryFactory creates the default inventory factory for HTTP mode
+func DefaultInventoryFactory(_ *ServerConfig, t translations.TranslationHelperFunc, featureChecker inventory.FeatureFlagChecker, scopeFetcher scopes.FetcherInterface) InventoryFactoryFunc {
+ return func(r *http.Request) (*inventory.Inventory, error) {
+ b := github.NewInventory(t).
+ WithDeprecatedAliases(github.DeprecatedToolAliases).
+ WithFeatureChecker(featureChecker)
+
+ b = InventoryFiltersForRequest(r, b)
+ b = PATScopeFilter(b, r, scopeFetcher)
+
+ b.WithServerInstructions()
+
+ return b.Build()
+ }
+}
+
+// InventoryFiltersForRequest applies filters to the inventory builder
+// based on the request context and headers
+func InventoryFiltersForRequest(r *http.Request, builder *inventory.Builder) *inventory.Builder {
+ ctx := r.Context()
+
+ if ghcontext.IsReadonly(ctx) {
+ builder = builder.WithReadOnly(true)
+ }
+
+ toolsets := ghcontext.GetToolsets(ctx)
+ tools := ghcontext.GetTools(ctx)
+
+ if len(toolsets) > 0 {
+ builder = builder.WithToolsets(github.ResolvedEnabledToolsets(false, toolsets, tools)) // No dynamic toolsets in HTTP mode
+ }
+
+ if len(tools) > 0 {
+ if len(toolsets) == 0 {
+ builder = builder.WithToolsets([]string{})
+ }
+ builder = builder.WithTools(github.CleanTools(tools))
+ }
+
+ return builder
+}
+
+func PATScopeFilter(b *inventory.Builder, r *http.Request, fetcher scopes.FetcherInterface) *inventory.Builder {
+ ctx := r.Context()
+
+ tokenInfo, ok := ghcontext.GetTokenInfo(ctx)
+ if !ok || tokenInfo == nil {
+ return b
+ }
+
+ // Scopes should have already been fetched by the WithPATScopes middleware.
+ // 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.
+ if tokenInfo.TokenType == utils.TokenTypePersonalAccessToken {
+ if tokenInfo.ScopesFetched {
+ return b.WithFilter(github.CreateToolScopeFilter(tokenInfo.Scopes))
+ }
+
+ scopesList, err := fetcher.FetchTokenScopes(ctx, tokenInfo.Token)
+ if err != nil {
+ return b
+ }
+
+ return b.WithFilter(github.CreateToolScopeFilter(scopesList))
+ }
+
+ return b
+}
diff --git a/pkg/http/handler_test.go b/pkg/http/handler_test.go
new file mode 100644
index 000000000..c92075569
--- /dev/null
+++ b/pkg/http/handler_test.go
@@ -0,0 +1,348 @@
+package http
+
+import (
+ "context"
+ "log/slog"
+ "net/http"
+ "net/http/httptest"
+ "sort"
+ "testing"
+
+ ghcontext "github.com/github/github-mcp-server/pkg/context"
+ "github.com/github/github-mcp-server/pkg/github"
+ "github.com/github/github-mcp-server/pkg/http/headers"
+ "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-chi/chi/v5"
+ "github.com/modelcontextprotocol/go-sdk/mcp"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func mockTool(name, toolsetID string, readOnly bool) inventory.ServerTool {
+ return inventory.ServerTool{
+ Tool: mcp.Tool{
+ Name: name,
+ Annotations: &mcp.ToolAnnotations{ReadOnlyHint: readOnly},
+ },
+ Toolset: inventory.ToolsetMetadata{
+ ID: inventory.ToolsetID(toolsetID),
+ Description: "Test: " + toolsetID,
+ },
+ }
+}
+
+type allScopesFetcher struct{}
+
+func (f allScopesFetcher) FetchTokenScopes(_ context.Context, _ string) ([]string, error) {
+ return []string{
+ string(scopes.Repo),
+ string(scopes.WriteOrg),
+ string(scopes.User),
+ string(scopes.Gist),
+ string(scopes.Notifications),
+ }, nil
+}
+
+var _ scopes.FetcherInterface = allScopesFetcher{}
+
+func mockToolWithFeatureFlag(name, toolsetID string, readOnly bool, enableFlag, disableFlag string) inventory.ServerTool {
+ tool := mockTool(name, toolsetID, readOnly)
+ tool.FeatureFlagEnable = enableFlag
+ tool.FeatureFlagDisable = disableFlag
+ return tool
+}
+
+func TestInventoryFiltersForRequest(t *testing.T) {
+ tools := []inventory.ServerTool{
+ mockTool("get_file_contents", "repos", true),
+ mockTool("create_repository", "repos", false),
+ mockTool("list_issues", "issues", true),
+ mockTool("issue_write", "issues", false),
+ }
+
+ tests := []struct {
+ name string
+ contextSetup func(context.Context) context.Context
+ expectedTools []string
+ }{
+ {
+ name: "no filters applies defaults",
+ contextSetup: func(ctx context.Context) context.Context { return ctx },
+ expectedTools: []string{"get_file_contents", "create_repository", "list_issues", "issue_write"},
+ },
+ {
+ name: "readonly from context filters write tools",
+ contextSetup: func(ctx context.Context) context.Context {
+ return ghcontext.WithReadonly(ctx, true)
+ },
+ expectedTools: []string{"get_file_contents", "list_issues"},
+ },
+ {
+ name: "toolset from context filters to toolset",
+ contextSetup: func(ctx context.Context) context.Context {
+ return ghcontext.WithToolsets(ctx, []string{"repos"})
+ },
+ expectedTools: []string{"get_file_contents", "create_repository"},
+ },
+ {
+ name: "tools alone clears default toolsets",
+ contextSetup: func(ctx context.Context) context.Context {
+ return ghcontext.WithTools(ctx, []string{"list_issues"})
+ },
+ expectedTools: []string{"list_issues"},
+ },
+ {
+ name: "tools are additive with toolsets",
+ contextSetup: func(ctx context.Context) context.Context {
+ ctx = ghcontext.WithToolsets(ctx, []string{"repos"})
+ ctx = ghcontext.WithTools(ctx, []string{"list_issues"})
+ return ctx
+ },
+ expectedTools: []string{"get_file_contents", "create_repository", "list_issues"},
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ req := httptest.NewRequest(http.MethodGet, "/", nil)
+ req = req.WithContext(tt.contextSetup(req.Context()))
+
+ builder := inventory.NewBuilder().
+ SetTools(tools).
+ WithToolsets([]string{"all"})
+
+ builder = InventoryFiltersForRequest(req, builder)
+ inv, err := builder.Build()
+ require.NoError(t, err)
+
+ available := inv.AvailableTools(context.Background())
+ toolNames := make([]string, len(available))
+ for i, tool := range available {
+ toolNames[i] = tool.Tool.Name
+ }
+
+ assert.ElementsMatch(t, tt.expectedTools, toolNames)
+ })
+ }
+}
+
+// testTools returns a set of mock tools across different toolsets with mixed read-only/write capabilities
+func testTools() []inventory.ServerTool {
+ return []inventory.ServerTool{
+ mockTool("get_file_contents", "repos", true),
+ mockTool("create_repository", "repos", false),
+ mockTool("list_issues", "issues", true),
+ mockTool("create_issue", "issues", false),
+ mockTool("list_pull_requests", "pull_requests", true),
+ mockTool("create_pull_request", "pull_requests", false),
+ // Feature-flagged tools for testing X-MCP-Features header
+ mockToolWithFeatureFlag("needs_holdback", "repos", true, "mcp_holdback_consolidated_projects", ""),
+ mockToolWithFeatureFlag("hidden_by_holdback", "repos", true, "", "mcp_holdback_consolidated_projects"),
+ }
+}
+
+// extractToolNames extracts tool names from an inventory
+func extractToolNames(ctx context.Context, inv *inventory.Inventory) []string {
+ available := inv.AvailableTools(ctx)
+ names := make([]string, len(available))
+ for i, tool := range available {
+ names[i] = tool.Tool.Name
+ }
+ sort.Strings(names)
+ return names
+}
+
+func TestHTTPHandlerRoutes(t *testing.T) {
+ tools := testTools()
+
+ tests := []struct {
+ name string
+ path string
+ headers map[string]string
+ expectedTools []string
+ }{
+ {
+ name: "root path returns all tools",
+ path: "/",
+ expectedTools: []string{"get_file_contents", "create_repository", "list_issues", "create_issue", "list_pull_requests", "create_pull_request", "hidden_by_holdback"},
+ },
+ {
+ name: "readonly path filters write tools",
+ path: "/readonly",
+ expectedTools: []string{"get_file_contents", "list_issues", "list_pull_requests", "hidden_by_holdback"},
+ },
+ {
+ name: "toolset path filters to toolset",
+ path: "/x/repos",
+ expectedTools: []string{"get_file_contents", "create_repository", "hidden_by_holdback"},
+ },
+ {
+ name: "toolset path with issues",
+ path: "/x/issues",
+ expectedTools: []string{"list_issues", "create_issue"},
+ },
+ {
+ name: "toolset readonly path filters to readonly tools in toolset",
+ path: "/x/repos/readonly",
+ expectedTools: []string{"get_file_contents", "hidden_by_holdback"},
+ },
+ {
+ name: "toolset readonly path with issues",
+ path: "/x/issues/readonly",
+ expectedTools: []string{"list_issues"},
+ },
+ {
+ name: "X-MCP-Tools header filters to specific tools",
+ path: "/",
+ headers: map[string]string{
+ headers.MCPToolsHeader: "list_issues",
+ },
+ expectedTools: []string{"list_issues"},
+ },
+ {
+ name: "X-MCP-Tools header with multiple tools",
+ path: "/",
+ headers: map[string]string{
+ headers.MCPToolsHeader: "list_issues,get_file_contents",
+ },
+ expectedTools: []string{"list_issues", "get_file_contents"},
+ },
+ {
+ name: "X-MCP-Tools header does not expose extra tools",
+ path: "/",
+ headers: map[string]string{
+ headers.MCPToolsHeader: "list_issues",
+ },
+ expectedTools: []string{"list_issues"},
+ },
+ {
+ name: "X-MCP-Readonly header filters write tools",
+ path: "/",
+ headers: map[string]string{
+ headers.MCPReadOnlyHeader: "true",
+ },
+ expectedTools: []string{"get_file_contents", "list_issues", "list_pull_requests", "hidden_by_holdback"},
+ },
+ {
+ name: "X-MCP-Toolsets header filters to toolset",
+ path: "/",
+ headers: map[string]string{
+ headers.MCPToolsetsHeader: "repos",
+ },
+ expectedTools: []string{"get_file_contents", "create_repository", "hidden_by_holdback"},
+ },
+ {
+ name: "URL toolset takes precedence over header toolset",
+ path: "/x/issues",
+ headers: map[string]string{
+ headers.MCPToolsetsHeader: "repos",
+ },
+ expectedTools: []string{"list_issues", "create_issue"},
+ },
+ {
+ name: "URL readonly takes precedence over header",
+ path: "/readonly",
+ headers: map[string]string{
+ headers.MCPReadOnlyHeader: "false",
+ },
+ expectedTools: []string{"get_file_contents", "list_issues", "list_pull_requests", "hidden_by_holdback"},
+ },
+ {
+ name: "X-MCP-Features header enables flagged tool",
+ path: "/",
+ headers: map[string]string{
+ headers.MCPFeaturesHeader: "mcp_holdback_consolidated_projects",
+ },
+ expectedTools: []string{"get_file_contents", "create_repository", "list_issues", "create_issue", "list_pull_requests", "create_pull_request", "needs_holdback"},
+ },
+ {
+ name: "X-MCP-Features header with unknown flag is ignored",
+ path: "/",
+ headers: map[string]string{
+ headers.MCPFeaturesHeader: "unknown_flag",
+ },
+ expectedTools: []string{"get_file_contents", "create_repository", "list_issues", "create_issue", "list_pull_requests", "create_pull_request", "hidden_by_holdback"},
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ var capturedInventory *inventory.Inventory
+ var capturedCtx context.Context
+
+ // Create feature checker that reads from context (same as production)
+ featureChecker := createHTTPFeatureChecker()
+
+ apiHost, err := utils.NewAPIHost("https://api.github.com")
+ require.NoError(t, err)
+
+ // Create inventory factory that captures the built inventory
+ inventoryFactory := func(r *http.Request) (*inventory.Inventory, error) {
+ capturedCtx = r.Context()
+ builder := inventory.NewBuilder().
+ SetTools(tools).
+ WithToolsets([]string{"all"}).
+ WithFeatureChecker(featureChecker)
+ builder = InventoryFiltersForRequest(r, builder)
+ inv, err := builder.Build()
+ if err != nil {
+ return nil, err
+ }
+ capturedInventory = inv
+ return inv, nil
+ }
+
+ // Create mock MCP server factory that just returns a minimal server
+ mcpServerFactory := func(_ *http.Request, _ github.ToolDependencies, _ *inventory.Inventory, _ *github.MCPServerConfig) (*mcp.Server, error) {
+ return mcp.NewServer(&mcp.Implementation{Name: "test", Version: "0.0.1"}, nil), nil
+ }
+
+ allScopesFetcher := allScopesFetcher{}
+
+ // Create handler with our factories
+ handler := NewHTTPMcpHandler(
+ context.Background(),
+ &ServerConfig{Version: "test"},
+ nil, // deps not needed for this test
+ translations.NullTranslationHelper,
+ slog.Default(),
+ apiHost,
+ WithInventoryFactory(inventoryFactory),
+ WithGitHubMCPServerFactory(mcpServerFactory),
+ WithScopeFetcher(allScopesFetcher),
+ )
+
+ // Create router and register routes
+ r := chi.NewRouter()
+ handler.RegisterMiddleware(r)
+ handler.RegisterRoutes(r)
+
+ // Create request
+ req := httptest.NewRequest(http.MethodPost, tt.path, nil)
+
+ // Ensure we're setting Authorization header for token context
+ req.Header.Set(headers.AuthorizationHeader, "Bearer ghp_testtoken")
+
+ for k, v := range tt.headers {
+ req.Header.Set(k, v)
+ }
+
+ // Execute request
+ rr := httptest.NewRecorder()
+ r.ServeHTTP(rr, req)
+
+ // Verify the inventory was captured and has the expected tools
+ require.NotNil(t, capturedInventory, "inventory should have been created")
+
+ toolNames := extractToolNames(capturedCtx, capturedInventory)
+ expectedSorted := make([]string, len(tt.expectedTools))
+ copy(expectedSorted, tt.expectedTools)
+ sort.Strings(expectedSorted)
+
+ assert.Equal(t, expectedSorted, toolNames, "tools should match expected")
+ })
+ }
+}
diff --git a/pkg/http/headers/headers.go b/pkg/http/headers/headers.go
new file mode 100644
index 000000000..bbc46b43f
--- /dev/null
+++ b/pkg/http/headers/headers.go
@@ -0,0 +1,53 @@
+package headers
+
+const (
+ // AuthorizationHeader is a standard HTTP Header.
+ AuthorizationHeader = "Authorization"
+ // ContentTypeHeader is a standard HTTP Header.
+ ContentTypeHeader = "Content-Type"
+ // AcceptHeader is a standard HTTP Header.
+ AcceptHeader = "Accept"
+ // UserAgentHeader is a standard HTTP Header.
+ UserAgentHeader = "User-Agent"
+
+ // ContentTypeJSON is the standard MIME type for JSON.
+ ContentTypeJSON = "application/json"
+ // ContentTypeEventStream is the standard MIME type for Event Streams.
+ ContentTypeEventStream = "text/event-stream"
+
+ // ForwardedForHeader is a standard HTTP Header used to forward the originating IP address of a client.
+ ForwardedForHeader = "X-Forwarded-For"
+
+ // RealIPHeader is a standard HTTP Header used to indicate the real IP address of the client.
+ RealIPHeader = "X-Real-IP"
+
+ // ForwardedHostHeader is a standard HTTP Header for preserving the original Host header when proxying.
+ ForwardedHostHeader = "X-Forwarded-Host"
+ // ForwardedProtoHeader is a standard HTTP Header for preserving the original protocol when proxying.
+ ForwardedProtoHeader = "X-Forwarded-Proto"
+
+ // RequestHmacHeader is used to authenticate requests to the Raw API.
+ RequestHmacHeader = "Request-Hmac"
+
+ // MCP-specific headers.
+
+ // MCPReadOnlyHeader indicates whether the MCP is in read-only mode.
+ MCPReadOnlyHeader = "X-MCP-Readonly"
+ // MCPToolsetsHeader is a comma-separated list of MCP toolsets that the request is for.
+ MCPToolsetsHeader = "X-MCP-Toolsets"
+ // MCPToolsHeader is a comma-separated list of MCP tools that the request is for.
+ MCPToolsHeader = "X-MCP-Tools"
+ // MCPLockdownHeader indicates whether lockdown mode is enabled.
+ MCPLockdownHeader = "X-MCP-Lockdown"
+ // MCPInsidersHeader indicates whether insiders mode is enabled for early access features.
+ MCPInsidersHeader = "X-MCP-Insiders"
+ // MCPFeaturesHeader is a comma-separated list of feature flags to enable.
+ MCPFeaturesHeader = "X-MCP-Features"
+
+ // GitHub-specific headers.
+
+ // GraphQLFeaturesHeader is a comma-separated list of GraphQL feature flags to enable for GraphQL requests.
+ GraphQLFeaturesHeader = "GraphQL-Features"
+ // GitHubAPIVersionHeader is the header used to specify the GitHub API version.
+ GitHubAPIVersionHeader = "X-GitHub-Api-Version"
+)
diff --git a/pkg/http/headers/parse.go b/pkg/http/headers/parse.go
new file mode 100644
index 000000000..2b5eddacd
--- /dev/null
+++ b/pkg/http/headers/parse.go
@@ -0,0 +1,21 @@
+package headers
+
+import "strings"
+
+// ParseCommaSeparated splits a header value by comma, trims whitespace,
+// and filters out empty values
+func ParseCommaSeparated(value string) []string {
+ if value == "" {
+ return []string{}
+ }
+
+ parts := strings.Split(value, ",")
+ result := make([]string, 0, len(parts))
+ for _, p := range parts {
+ trimmed := strings.TrimSpace(p)
+ if trimmed != "" {
+ result = append(result, trimmed)
+ }
+ }
+ return result
+}
diff --git a/pkg/http/headers/parse_test.go b/pkg/http/headers/parse_test.go
new file mode 100644
index 000000000..d8b55a696
--- /dev/null
+++ b/pkg/http/headers/parse_test.go
@@ -0,0 +1,58 @@
+package headers
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestParseCommaSeparated(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ expected []string
+ }{
+ {
+ name: "empty string",
+ input: "",
+ expected: []string{},
+ },
+ {
+ name: "single value",
+ input: "foo",
+ expected: []string{"foo"},
+ },
+ {
+ name: "multiple values",
+ input: "foo,bar,baz",
+ expected: []string{"foo", "bar", "baz"},
+ },
+ {
+ name: "whitespace trimmed",
+ input: " foo , bar , baz ",
+ expected: []string{"foo", "bar", "baz"},
+ },
+ {
+ name: "empty values filtered",
+ input: "foo,,bar,",
+ expected: []string{"foo", "bar"},
+ },
+ {
+ name: "only commas",
+ input: ",,,",
+ expected: []string{},
+ },
+ {
+ name: "whitespace only values filtered",
+ input: "foo, ,bar",
+ expected: []string{"foo", "bar"},
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := ParseCommaSeparated(tt.input)
+ assert.Equal(t, tt.expected, result)
+ })
+ }
+}
diff --git a/pkg/http/mark/mark.go b/pkg/http/mark/mark.go
new file mode 100644
index 000000000..859a30923
--- /dev/null
+++ b/pkg/http/mark/mark.go
@@ -0,0 +1,65 @@
+// Package mark provides a mechanism for tagging errors with a well-known error value.
+package mark
+
+import "errors"
+
+// This list of errors is not exhaustive, but is a good starting point for most
+// applications. Feel free to add more as needed, but don't go overboard.
+// Remember, the specific types of errors are only important so far as someone
+// calling your code might want to write logic to handle each type of error
+// differently.
+//
+// Do not add application-specific errors to this list. Instead, just define
+// your own package with your own application-specific errors, and use this
+// package to mark errors with them. The errors in this package are not special,
+// they're just plain old errors.
+//
+// Not all errors need to be marked. An error that is not marked should be
+// treated as an unexpected error that cannot be handled by calling code. This
+// is often the case for network errors or logic errors.
+var (
+ ErrNotFound = errors.New("not found")
+ ErrAlreadyExists = errors.New("already exists")
+ ErrBadRequest = errors.New("bad request")
+ ErrUnauthorized = errors.New("unauthorized")
+ ErrCancelled = errors.New("request cancelled")
+ ErrUnavailable = errors.New("unavailable")
+ ErrTimedout = errors.New("request timed out")
+ ErrTooLarge = errors.New("request is too large")
+ ErrTooManyRequests = errors.New("too many requests")
+ ErrForbidden = errors.New("forbidden")
+)
+
+// With wraps err with another error that will return true from errors.Is and
+// errors.As for both err and markErr, and anything either may wrap.
+func With(err, markErr error) error {
+ if err == nil {
+ return nil
+ }
+ return marked{wrapped: err, mark: markErr}
+}
+
+type marked struct {
+ wrapped error
+ mark error
+}
+
+func (f marked) Is(target error) bool {
+ // if this is false, errors.Is will call unwrap and retry on the wrapped
+ // error.
+ return errors.Is(f.mark, target)
+}
+
+func (f marked) As(target any) bool {
+ // if this is false, errors.As will call unwrap and retry on the wrapped
+ // error.
+ return errors.As(f.mark, target)
+}
+
+func (f marked) Unwrap() error {
+ return f.wrapped
+}
+
+func (f marked) Error() string {
+ return f.mark.Error() + ": " + f.wrapped.Error()
+}
diff --git a/pkg/http/middleware/mcp_parse.go b/pkg/http/middleware/mcp_parse.go
new file mode 100644
index 000000000..c82616b27
--- /dev/null
+++ b/pkg/http/middleware/mcp_parse.go
@@ -0,0 +1,126 @@
+package middleware
+
+import (
+ "bytes"
+ "encoding/json"
+ "io"
+ "net/http"
+
+ ghcontext "github.com/github/github-mcp-server/pkg/context"
+)
+
+// mcpJSONRPCRequest represents the structure of an MCP JSON-RPC request.
+// We only parse the fields needed for routing and optimization.
+type mcpJSONRPCRequest struct {
+ JSONRPC string `json:"jsonrpc"`
+ Method string `json:"method"`
+ Params struct {
+ // For tools/call
+ Name string `json:"name,omitempty"`
+ Arguments json.RawMessage `json:"arguments,omitempty"`
+ // For prompts/get
+ // Name is shared with tools/call
+ // For resources/read
+ URI string `json:"uri,omitempty"`
+ } `json:"params"`
+}
+
+// WithMCPParse creates a middleware that parses MCP JSON-RPC requests early in the
+// request lifecycle and stores the parsed information in the request context.
+// This enables:
+// - Registry filtering via ForMCPRequest (only register needed tools/resources/prompts)
+// - Avoiding duplicate JSON parsing in downstream middlewares
+// - Access to owner/repo for secret-scanning middleware
+//
+// The middleware reads the request body, parses it, restores the body for downstream
+// handlers, and stores the parsed MCPMethodInfo in the request context.
+func WithMCPParse() func(http.Handler) http.Handler {
+ return func(next http.Handler) http.Handler {
+ fn := func(w http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+
+ // Skip health check endpoints
+ if r.URL.Path == "/_ping" {
+ next.ServeHTTP(w, r)
+ return
+ }
+
+ // Only parse POST requests (MCP uses JSON-RPC over POST)
+ if r.Method != http.MethodPost {
+ next.ServeHTTP(w, r)
+ return
+ }
+
+ // Read the request body
+ body, err := io.ReadAll(r.Body)
+ if err != nil {
+ // Log but continue - don't block requests on parse errors
+ next.ServeHTTP(w, r)
+ return
+ }
+
+ // Restore the body for downstream handlers
+ r.Body = io.NopCloser(bytes.NewReader(body))
+
+ // Skip empty bodies
+ if len(body) == 0 {
+ next.ServeHTTP(w, r)
+ return
+ }
+
+ // Parse the JSON-RPC request
+ var mcpReq mcpJSONRPCRequest
+ err = json.Unmarshal(body, &mcpReq)
+ if err != nil {
+ // Log but continue - could be a non-MCP request or malformed JSON
+ next.ServeHTTP(w, r)
+ return
+ }
+
+ // Skip if not a valid JSON-RPC 2.0 request
+ if mcpReq.JSONRPC != "2.0" || mcpReq.Method == "" {
+ next.ServeHTTP(w, r)
+ return
+ }
+
+ // Build the MCPMethodInfo
+ methodInfo := &ghcontext.MCPMethodInfo{
+ Method: mcpReq.Method,
+ }
+
+ // Extract item name based on method type
+
+ switch mcpReq.Method {
+ case "tools/call":
+ methodInfo.ItemName = mcpReq.Params.Name
+ // Parse arguments if present
+ if len(mcpReq.Params.Arguments) > 0 {
+ var args map[string]any
+ err := json.Unmarshal(mcpReq.Params.Arguments, &args)
+ if err == nil {
+ methodInfo.Arguments = args
+ // Extract owner and repo if present
+ if owner, ok := args["owner"].(string); ok {
+ methodInfo.Owner = owner
+ }
+ if repo, ok := args["repo"].(string); ok {
+ methodInfo.Repo = repo
+ }
+ }
+ }
+ case "prompts/get":
+ methodInfo.ItemName = mcpReq.Params.Name
+ case "resources/read":
+ methodInfo.ItemName = mcpReq.Params.URI
+ default:
+ // Whatever
+ }
+
+ // Store the parsed info in context
+ ctx = ghcontext.WithMCPMethodInfo(ctx, methodInfo)
+
+ next.ServeHTTP(w, r.WithContext(ctx))
+ }
+ return http.HandlerFunc(fn)
+ }
+}
diff --git a/pkg/http/middleware/mcp_parse_test.go b/pkg/http/middleware/mcp_parse_test.go
new file mode 100644
index 000000000..5a28a30c3
--- /dev/null
+++ b/pkg/http/middleware/mcp_parse_test.go
@@ -0,0 +1,191 @@
+package middleware
+
+import (
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ ghcontext "github.com/github/github-mcp-server/pkg/context"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestWithMCPParse(t *testing.T) {
+ tests := []struct {
+ name string
+ method string
+ path string
+ body string
+ expectInfo bool
+ expectedMethod string
+ expectedItem string
+ expectedOwner string
+ expectedRepo string
+ expectedArgs map[string]any
+ }{
+ {
+ name: "health check path is skipped",
+ method: http.MethodPost,
+ path: "/_ping",
+ body: `{"jsonrpc":"2.0","method":"tools/list"}`,
+ expectInfo: false,
+ },
+ {
+ name: "GET request is skipped",
+ method: http.MethodGet,
+ path: "/mcp",
+ body: `{"jsonrpc":"2.0","method":"tools/list"}`,
+ expectInfo: false,
+ },
+ {
+ name: "empty body is skipped",
+ method: http.MethodPost,
+ path: "/mcp",
+ body: "",
+ expectInfo: false,
+ },
+ {
+ name: "invalid JSON is skipped",
+ method: http.MethodPost,
+ path: "/mcp",
+ body: "not valid json",
+ expectInfo: false,
+ },
+ {
+ name: "non-JSON-RPC 2.0 is skipped",
+ method: http.MethodPost,
+ path: "/mcp",
+ body: `{"jsonrpc":"1.0","method":"tools/list"}`,
+ expectInfo: false,
+ },
+ {
+ name: "empty method is skipped",
+ method: http.MethodPost,
+ path: "/mcp",
+ body: `{"jsonrpc":"2.0","method":""}`,
+ expectInfo: false,
+ },
+ {
+ name: "tools/list parses method only",
+ method: http.MethodPost,
+ path: "/mcp",
+ body: `{"jsonrpc":"2.0","method":"tools/list"}`,
+ expectInfo: true,
+ expectedMethod: "tools/list",
+ },
+ {
+ name: "tools/call parses name",
+ method: http.MethodPost,
+ path: "/mcp",
+ body: `{"jsonrpc":"2.0","method":"tools/call","params":{"name":"get_file_contents"}}`,
+ expectInfo: true,
+ expectedMethod: "tools/call",
+ expectedItem: "get_file_contents",
+ },
+ {
+ name: "tools/call parses owner and repo from arguments",
+ method: http.MethodPost,
+ path: "/mcp",
+ body: `{"jsonrpc":"2.0","method":"tools/call","params":{"name":"get_file_contents","arguments":{"owner":"github","repo":"github-mcp-server","path":"README.md"}}}`,
+ expectInfo: true,
+ expectedMethod: "tools/call",
+ expectedItem: "get_file_contents",
+ expectedOwner: "github",
+ expectedRepo: "github-mcp-server",
+ expectedArgs: map[string]any{"owner": "github", "repo": "github-mcp-server", "path": "README.md"},
+ },
+ {
+ name: "tools/call with invalid arguments JSON continues without args",
+ method: http.MethodPost,
+ path: "/mcp",
+ body: `{"jsonrpc":"2.0","method":"tools/call","params":{"name":"get_file_contents","arguments":"not an object"}}`,
+ expectInfo: true,
+ expectedMethod: "tools/call",
+ expectedItem: "get_file_contents",
+ },
+ {
+ name: "prompts/get parses name",
+ method: http.MethodPost,
+ path: "/mcp",
+ body: `{"jsonrpc":"2.0","method":"prompts/get","params":{"name":"my_prompt"}}`,
+ expectInfo: true,
+ expectedMethod: "prompts/get",
+ expectedItem: "my_prompt",
+ },
+ {
+ name: "resources/read parses URI as item name",
+ method: http.MethodPost,
+ path: "/mcp",
+ body: `{"jsonrpc":"2.0","method":"resources/read","params":{"uri":"repo://github/github-mcp-server"}}`,
+ expectInfo: true,
+ expectedMethod: "resources/read",
+ expectedItem: "repo://github/github-mcp-server",
+ },
+ {
+ name: "initialize method parses correctly",
+ method: http.MethodPost,
+ path: "/mcp",
+ body: `{"jsonrpc":"2.0","method":"initialize","params":{"capabilities":{}}}`,
+ expectInfo: true,
+ expectedMethod: "initialize",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ var capturedInfo *ghcontext.MCPMethodInfo
+ var infoCaptured bool
+
+ // Create a handler that captures the MCPMethodInfo from context
+ nextHandler := http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
+ capturedInfo, infoCaptured = ghcontext.MCPMethod(r.Context())
+ })
+
+ middleware := WithMCPParse()
+ handler := middleware(nextHandler)
+
+ req := httptest.NewRequest(tt.method, tt.path, strings.NewReader(tt.body))
+ rr := httptest.NewRecorder()
+
+ handler.ServeHTTP(rr, req)
+
+ if tt.expectInfo {
+ require.True(t, infoCaptured, "MCPMethodInfo should be present in context")
+ require.NotNil(t, capturedInfo)
+ assert.Equal(t, tt.expectedMethod, capturedInfo.Method)
+ assert.Equal(t, tt.expectedItem, capturedInfo.ItemName)
+ assert.Equal(t, tt.expectedOwner, capturedInfo.Owner)
+ assert.Equal(t, tt.expectedRepo, capturedInfo.Repo)
+ if tt.expectedArgs != nil {
+ assert.Equal(t, tt.expectedArgs, capturedInfo.Arguments)
+ }
+ } else {
+ assert.False(t, infoCaptured, "MCPMethodInfo should not be present in context")
+ }
+ })
+ }
+}
+
+func TestWithMCPParse_BodyRestoration(t *testing.T) {
+ originalBody := `{"jsonrpc":"2.0","method":"tools/call","params":{"name":"test_tool"}}`
+
+ var capturedBody string
+
+ nextHandler := http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
+ body, err := io.ReadAll(r.Body)
+ require.NoError(t, err)
+ capturedBody = string(body)
+ })
+
+ middleware := WithMCPParse()
+ handler := middleware(nextHandler)
+
+ req := httptest.NewRequest(http.MethodPost, "/mcp", strings.NewReader(originalBody))
+ rr := httptest.NewRecorder()
+
+ handler.ServeHTTP(rr, req)
+
+ assert.Equal(t, originalBody, capturedBody, "body should be restored for downstream handlers")
+}
diff --git a/pkg/http/middleware/pat_scope.go b/pkg/http/middleware/pat_scope.go
new file mode 100644
index 000000000..8b77b3d32
--- /dev/null
+++ b/pkg/http/middleware/pat_scope.go
@@ -0,0 +1,50 @@
+package middleware
+
+import (
+ "log/slog"
+ "net/http"
+
+ ghcontext "github.com/github/github-mcp-server/pkg/context"
+ "github.com/github/github-mcp-server/pkg/scopes"
+ "github.com/github/github-mcp-server/pkg/utils"
+)
+
+// WithPATScopes is a middleware that fetches and stores scopes for classic Personal Access Tokens (PATs) in the request context.
+func WithPATScopes(logger *slog.Logger, scopeFetcher scopes.FetcherInterface) func(http.Handler) http.Handler {
+ return func(next http.Handler) http.Handler {
+ fn := func(w http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+
+ tokenInfo, ok := ghcontext.GetTokenInfo(ctx)
+ if !ok || tokenInfo == nil {
+ logger.Warn("no token info found in context")
+ next.ServeHTTP(w, r)
+ return
+ }
+
+ // 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.
+ if tokenInfo.TokenType == utils.TokenTypePersonalAccessToken {
+ scopesList, err := scopeFetcher.FetchTokenScopes(ctx, tokenInfo.Token)
+ if err != nil {
+ logger.Warn("failed to fetch PAT scopes", "error", err)
+ next.ServeHTTP(w, r)
+ return
+ }
+
+ tokenInfo.Scopes = scopesList
+ tokenInfo.ScopesFetched = true
+
+ // Store fetched scopes in context for downstream use
+ ctx := ghcontext.WithTokenInfo(ctx, tokenInfo)
+
+ next.ServeHTTP(w, r.WithContext(ctx))
+ return
+ }
+
+ next.ServeHTTP(w, r)
+ }
+ return http.HandlerFunc(fn)
+ }
+}
diff --git a/pkg/http/middleware/pat_scope_test.go b/pkg/http/middleware/pat_scope_test.go
new file mode 100644
index 000000000..eb472bcf1
--- /dev/null
+++ b/pkg/http/middleware/pat_scope_test.go
@@ -0,0 +1,187 @@
+package middleware
+
+import (
+ "context"
+ "errors"
+ "log/slog"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ ghcontext "github.com/github/github-mcp-server/pkg/context"
+ "github.com/github/github-mcp-server/pkg/utils"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// mockScopeFetcher is a mock implementation of scopes.FetcherInterface
+type mockScopeFetcher struct {
+ scopes []string
+ err error
+}
+
+func (m *mockScopeFetcher) FetchTokenScopes(_ context.Context, _ string) ([]string, error) {
+ return m.scopes, m.err
+}
+
+func TestWithPATScopes(t *testing.T) {
+ logger := slog.Default()
+
+ tests := []struct {
+ name string
+ tokenInfo *ghcontext.TokenInfo
+ fetcherScopes []string
+ fetcherErr error
+ expectScopesFetched bool
+ expectedScopes []string
+ expectNextHandlerCalled bool
+ }{
+ {
+ name: "no token info in context calls next handler",
+ tokenInfo: nil,
+ expectScopesFetched: false,
+ expectedScopes: nil,
+ expectNextHandlerCalled: true,
+ },
+ {
+ name: "non-PAT token type skips scope fetching",
+ tokenInfo: &ghcontext.TokenInfo{
+ Token: "gho_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
+ TokenType: utils.TokenTypeOAuthAccessToken,
+ },
+ expectScopesFetched: false,
+ expectedScopes: nil,
+ expectNextHandlerCalled: true,
+ },
+ {
+ name: "fine-grained PAT skips scope fetching",
+ tokenInfo: &ghcontext.TokenInfo{
+ Token: "github_pat_xxxxxxxxxxxxxxxxxxxxxxx",
+ TokenType: utils.TokenTypeFineGrainedPersonalAccessToken,
+ },
+ expectScopesFetched: false,
+ expectedScopes: nil,
+ expectNextHandlerCalled: true,
+ },
+ {
+ name: "classic PAT fetches and stores scopes",
+ tokenInfo: &ghcontext.TokenInfo{
+ Token: "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
+ TokenType: utils.TokenTypePersonalAccessToken,
+ },
+ fetcherScopes: []string{"repo", "user", "read:org"},
+ expectScopesFetched: true,
+ expectedScopes: []string{"repo", "user", "read:org"},
+ expectNextHandlerCalled: true,
+ },
+ {
+ name: "classic PAT with empty scopes",
+ tokenInfo: &ghcontext.TokenInfo{
+ Token: "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
+ TokenType: utils.TokenTypePersonalAccessToken,
+ },
+ fetcherScopes: []string{},
+ expectScopesFetched: true,
+ expectedScopes: []string{},
+ expectNextHandlerCalled: true,
+ },
+ {
+ name: "fetcher error calls next handler without scopes",
+ tokenInfo: &ghcontext.TokenInfo{
+ Token: "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
+ TokenType: utils.TokenTypePersonalAccessToken,
+ },
+ fetcherErr: errors.New("network error"),
+ expectScopesFetched: false,
+ expectedScopes: nil,
+ expectNextHandlerCalled: true,
+ },
+ {
+ name: "old-style PAT (40 hex chars) fetches scopes",
+ tokenInfo: &ghcontext.TokenInfo{
+ Token: "0123456789abcdef0123456789abcdef01234567",
+ TokenType: utils.TokenTypePersonalAccessToken,
+ },
+ fetcherScopes: []string{"repo"},
+ expectScopesFetched: true,
+ expectedScopes: []string{"repo"},
+ expectNextHandlerCalled: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ var capturedTokenInfo *ghcontext.TokenInfo
+ var nextHandlerCalled bool
+
+ nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ nextHandlerCalled = true
+ capturedTokenInfo, _ = ghcontext.GetTokenInfo(r.Context())
+ w.WriteHeader(http.StatusOK)
+ })
+
+ fetcher := &mockScopeFetcher{
+ scopes: tt.fetcherScopes,
+ err: tt.fetcherErr,
+ }
+
+ middleware := WithPATScopes(logger, fetcher)
+ handler := middleware(nextHandler)
+
+ req := httptest.NewRequest(http.MethodGet, "/test", nil)
+
+ // Set up context with token info if provided
+ if tt.tokenInfo != nil {
+ ctx := ghcontext.WithTokenInfo(req.Context(), tt.tokenInfo)
+ req = req.WithContext(ctx)
+ }
+
+ rr := httptest.NewRecorder()
+ handler.ServeHTTP(rr, req)
+
+ assert.Equal(t, tt.expectNextHandlerCalled, nextHandlerCalled, "next handler called mismatch")
+
+ if tt.expectNextHandlerCalled && tt.tokenInfo != nil {
+ require.NotNil(t, capturedTokenInfo, "expected token info in context")
+ assert.Equal(t, tt.expectScopesFetched, capturedTokenInfo.ScopesFetched)
+ assert.Equal(t, tt.expectedScopes, capturedTokenInfo.Scopes)
+ }
+ })
+ }
+}
+
+func TestWithPATScopes_PreservesExistingTokenInfo(t *testing.T) {
+ logger := slog.Default()
+
+ var capturedTokenInfo *ghcontext.TokenInfo
+
+ nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ capturedTokenInfo, _ = ghcontext.GetTokenInfo(r.Context())
+ w.WriteHeader(http.StatusOK)
+ })
+
+ fetcher := &mockScopeFetcher{
+ scopes: []string{"repo", "user"},
+ }
+
+ originalTokenInfo := &ghcontext.TokenInfo{
+ Token: "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
+ TokenType: utils.TokenTypePersonalAccessToken,
+ }
+
+ middleware := WithPATScopes(logger, fetcher)
+ handler := middleware(nextHandler)
+
+ req := httptest.NewRequest(http.MethodGet, "/test", nil)
+ ctx := ghcontext.WithTokenInfo(req.Context(), originalTokenInfo)
+ req = req.WithContext(ctx)
+
+ rr := httptest.NewRecorder()
+ handler.ServeHTTP(rr, req)
+
+ require.NotNil(t, capturedTokenInfo)
+ assert.Equal(t, originalTokenInfo.Token, capturedTokenInfo.Token)
+ assert.Equal(t, originalTokenInfo.TokenType, capturedTokenInfo.TokenType)
+ assert.True(t, capturedTokenInfo.ScopesFetched)
+ assert.Equal(t, []string{"repo", "user"}, capturedTokenInfo.Scopes)
+}
diff --git a/pkg/http/middleware/request_config.go b/pkg/http/middleware/request_config.go
new file mode 100644
index 000000000..5cabe16eb
--- /dev/null
+++ b/pkg/http/middleware/request_config.go
@@ -0,0 +1,59 @@
+package middleware
+
+import (
+ "net/http"
+ "slices"
+ "strings"
+
+ ghcontext "github.com/github/github-mcp-server/pkg/context"
+ "github.com/github/github-mcp-server/pkg/http/headers"
+)
+
+// WithRequestConfig is a middleware that extracts MCP-related headers and sets them in the request context.
+// This includes readonly mode, toolsets, tools, lockdown mode, insiders mode, and feature flags.
+func WithRequestConfig(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+
+ // Readonly mode
+ if relaxedParseBool(r.Header.Get(headers.MCPReadOnlyHeader)) {
+ ctx = ghcontext.WithReadonly(ctx, true)
+ }
+
+ // Toolsets
+ if toolsets := headers.ParseCommaSeparated(r.Header.Get(headers.MCPToolsetsHeader)); len(toolsets) > 0 {
+ ctx = ghcontext.WithToolsets(ctx, toolsets)
+ }
+
+ // Tools
+ if tools := headers.ParseCommaSeparated(r.Header.Get(headers.MCPToolsHeader)); len(tools) > 0 {
+ ctx = ghcontext.WithTools(ctx, tools)
+ }
+
+ // Lockdown mode
+ if relaxedParseBool(r.Header.Get(headers.MCPLockdownHeader)) {
+ ctx = ghcontext.WithLockdownMode(ctx, true)
+ }
+
+ // Insiders mode
+ if relaxedParseBool(r.Header.Get(headers.MCPInsidersHeader)) {
+ ctx = ghcontext.WithInsidersMode(ctx, true)
+ }
+
+ // Feature flags
+ if features := headers.ParseCommaSeparated(r.Header.Get(headers.MCPFeaturesHeader)); len(features) > 0 {
+ ctx = ghcontext.WithHeaderFeatures(ctx, features)
+ }
+
+ next.ServeHTTP(w, r.WithContext(ctx))
+ })
+}
+
+// relaxedParseBool parses a string into a boolean value, treating various
+// common false values or empty strings as false, and everything else as true.
+// It is case-insensitive and trims whitespace.
+func relaxedParseBool(s string) bool {
+ s = strings.TrimSpace(strings.ToLower(s))
+ falseValues := []string{"", "false", "0", "no", "off", "n", "f"}
+ return !slices.Contains(falseValues, s)
+}
diff --git a/pkg/http/middleware/scope_challenge.go b/pkg/http/middleware/scope_challenge.go
new file mode 100644
index 000000000..526797241
--- /dev/null
+++ b/pkg/http/middleware/scope_challenge.go
@@ -0,0 +1,143 @@
+package middleware
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+
+ ghcontext "github.com/github/github-mcp-server/pkg/context"
+ "github.com/github/github-mcp-server/pkg/http/oauth"
+ "github.com/github/github-mcp-server/pkg/scopes"
+ "github.com/github/github-mcp-server/pkg/utils"
+)
+
+// WithScopeChallenge creates a new middleware that determines if an OAuth request contains sufficient scopes to
+// complete the request and returns a scope challenge if not.
+func WithScopeChallenge(oauthCfg *oauth.Config, scopeFetcher scopes.FetcherInterface) func(http.Handler) http.Handler {
+ return func(next http.Handler) http.Handler {
+ fn := func(w http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+
+ // Skip health check endpoints
+ if r.URL.Path == "/_ping" {
+ next.ServeHTTP(w, r)
+ return
+ }
+
+ // Get user from context
+ tokenInfo, ok := ghcontext.GetTokenInfo(ctx)
+ if !ok {
+ next.ServeHTTP(w, r)
+ return
+ }
+
+ // Only check OAuth tokens - scope challenge allows OAuth apps to request additional scopes
+ if tokenInfo.TokenType != utils.TokenTypeOAuthAccessToken {
+ next.ServeHTTP(w, r)
+ return
+ }
+
+ // Try to use pre-parsed MCP method info first (performance optimization)
+ // This avoids re-parsing the JSON body if WithMCPParse middleware ran earlier
+ var toolName string
+ if methodInfo, ok := ghcontext.MCPMethod(ctx); ok && methodInfo != nil {
+ // Only check tools/call requests
+ if methodInfo.Method != "tools/call" {
+ next.ServeHTTP(w, r)
+ return
+ }
+ toolName = methodInfo.ItemName
+ } else {
+ // Fallback: parse the request body directly
+ body, err := io.ReadAll(r.Body)
+ if err != nil {
+ next.ServeHTTP(w, r)
+ return
+ }
+ r.Body = io.NopCloser(bytes.NewReader(body))
+
+ var mcpRequest struct {
+ JSONRPC string `json:"jsonrpc"`
+ Method string `json:"method"`
+ Params struct {
+ Name string `json:"name,omitempty"`
+ Arguments map[string]any `json:"arguments,omitempty"`
+ } `json:"params"`
+ }
+
+ err = json.Unmarshal(body, &mcpRequest)
+ if err != nil {
+ next.ServeHTTP(w, r)
+ return
+ }
+
+ // Only check tools/call requests
+ if mcpRequest.Method != "tools/call" {
+ next.ServeHTTP(w, r)
+ return
+ }
+
+ toolName = mcpRequest.Params.Name
+ }
+ toolScopeInfo, err := scopes.GetToolScopeInfo(toolName)
+ if err != nil {
+ next.ServeHTTP(w, r)
+ return
+ }
+
+ // If tool not found in scope map, allow the request
+ if toolScopeInfo == nil {
+ next.ServeHTTP(w, r)
+ return
+ }
+
+ // Get OAuth scopes from GitHub API
+ activeScopes, err := scopeFetcher.FetchTokenScopes(ctx, tokenInfo.Token)
+ if err != nil {
+ next.ServeHTTP(w, r)
+ return
+ }
+
+ // Store active scopes in context for downstream use
+ tokenInfo.Scopes = activeScopes
+ tokenInfo.ScopesFetched = true
+ ctx = ghcontext.WithTokenInfo(ctx, tokenInfo)
+ r = r.WithContext(ctx)
+
+ // Check if user has the required scopes
+ if toolScopeInfo.HasAcceptedScope(activeScopes...) {
+ next.ServeHTTP(w, r)
+ return
+ }
+
+ // User lacks required scopes - get the scopes they need
+ requiredScopes := toolScopeInfo.GetRequiredScopesSlice()
+
+ // Build the resource metadata URL using the shared utility
+ // GetEffectiveResourcePath returns the original path (e.g., /mcp or /mcp/x/all)
+ // which is used to construct the well-known OAuth protected resource URL
+ resourcePath := oauth.ResolveResourcePath(r, oauthCfg)
+ resourceMetadataURL := oauth.BuildResourceMetadataURL(r, oauthCfg, resourcePath)
+
+ // Build recommended scopes: existing scopes + required scopes
+ recommendedScopes := make([]string, 0, len(activeScopes)+len(requiredScopes))
+ recommendedScopes = append(recommendedScopes, activeScopes...)
+ recommendedScopes = append(recommendedScopes, requiredScopes...)
+
+ // Build the WWW-Authenticate header value
+ wwwAuthenticateHeader := fmt.Sprintf(`Bearer error="insufficient_scope", scope=%q, resource_metadata=%q, error_description=%q`,
+ strings.Join(recommendedScopes, " "),
+ resourceMetadataURL,
+ "Additional scopes required: "+strings.Join(requiredScopes, ", "),
+ )
+
+ // Send scope challenge response with the superset of existing and required scopes
+ w.Header().Set("WWW-Authenticate", wwwAuthenticateHeader)
+ http.Error(w, "Forbidden: insufficient scopes", http.StatusForbidden)
+ }
+ return http.HandlerFunc(fn)
+ }
+}
diff --git a/pkg/http/middleware/token.go b/pkg/http/middleware/token.go
new file mode 100644
index 000000000..c362ea201
--- /dev/null
+++ b/pkg/http/middleware/token.go
@@ -0,0 +1,47 @@
+package middleware
+
+import (
+ "errors"
+ "fmt"
+ "net/http"
+
+ ghcontext "github.com/github/github-mcp-server/pkg/context"
+ "github.com/github/github-mcp-server/pkg/http/oauth"
+ "github.com/github/github-mcp-server/pkg/utils"
+)
+
+func ExtractUserToken(oauthCfg *oauth.Config) func(next http.Handler) http.Handler {
+ return func(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ tokenType, token, err := utils.ParseAuthorizationHeader(r)
+ if err != nil {
+ // For missing Authorization header, return 401 with WWW-Authenticate header per MCP spec
+ if errors.Is(err, utils.ErrMissingAuthorizationHeader) {
+ sendAuthChallenge(w, r, oauthCfg)
+ return
+ }
+ // For other auth errors (bad format, unsupported), return 400
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+
+ ctx := r.Context()
+ ctx = ghcontext.WithTokenInfo(ctx, &ghcontext.TokenInfo{
+ Token: token,
+ TokenType: tokenType,
+ })
+ r = r.WithContext(ctx)
+
+ next.ServeHTTP(w, r)
+ })
+ }
+}
+
+// sendAuthChallenge sends a 401 Unauthorized response with WWW-Authenticate header
+// containing the OAuth protected resource metadata URL as per RFC 6750 and MCP spec.
+func sendAuthChallenge(w http.ResponseWriter, r *http.Request, oauthCfg *oauth.Config) {
+ resourcePath := oauth.ResolveResourcePath(r, oauthCfg)
+ resourceMetadataURL := oauth.BuildResourceMetadataURL(r, oauthCfg, resourcePath)
+ w.Header().Set("WWW-Authenticate", fmt.Sprintf(`Bearer resource_metadata=%q`, resourceMetadataURL))
+ http.Error(w, "Unauthorized", http.StatusUnauthorized)
+}
diff --git a/pkg/http/middleware/token_test.go b/pkg/http/middleware/token_test.go
new file mode 100644
index 000000000..fa8f0ee98
--- /dev/null
+++ b/pkg/http/middleware/token_test.go
@@ -0,0 +1,321 @@
+package middleware
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ ghcontext "github.com/github/github-mcp-server/pkg/context"
+ "github.com/github/github-mcp-server/pkg/http/headers"
+ "github.com/github/github-mcp-server/pkg/http/oauth"
+ "github.com/github/github-mcp-server/pkg/utils"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestExtractUserToken(t *testing.T) {
+ oauthCfg := &oauth.Config{
+ BaseURL: "https://example.com",
+ AuthorizationServer: "https://github.com/login/oauth",
+ }
+
+ tests := []struct {
+ name string
+ authHeader string
+ expectedStatusCode int
+ expectedTokenType utils.TokenType
+ expectedToken string
+ expectTokenInfo bool
+ expectWWWAuth bool
+ }{
+ // Missing authorization header
+ {
+ name: "missing Authorization header returns 401 with WWW-Authenticate",
+ authHeader: "",
+ expectedStatusCode: http.StatusUnauthorized,
+ expectTokenInfo: false,
+ expectWWWAuth: true,
+ },
+ // Personal Access Token (classic) - ghp_ prefix
+ {
+ name: "personal access token (classic) with Bearer prefix",
+ authHeader: "Bearer ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
+ expectedStatusCode: http.StatusOK,
+ expectedTokenType: utils.TokenTypePersonalAccessToken,
+ expectedToken: "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
+ expectTokenInfo: true,
+ },
+ {
+ name: "personal access token (classic) with bearer lowercase",
+ authHeader: "bearer ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
+ expectedStatusCode: http.StatusOK,
+ expectedTokenType: utils.TokenTypePersonalAccessToken,
+ expectedToken: "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
+ expectTokenInfo: true,
+ },
+ {
+ name: "personal access token (classic) without Bearer prefix",
+ authHeader: "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
+ expectedStatusCode: http.StatusOK,
+ expectedTokenType: utils.TokenTypePersonalAccessToken,
+ expectedToken: "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
+ expectTokenInfo: true,
+ },
+ // Fine-grained Personal Access Token - github_pat_ prefix
+ {
+ name: "fine-grained personal access token with Bearer prefix",
+ authHeader: "Bearer github_pat_xxxxxxxxxxxxxxxxxxxxxxx",
+ expectedStatusCode: http.StatusOK,
+ expectedTokenType: utils.TokenTypeFineGrainedPersonalAccessToken,
+ expectedToken: "github_pat_xxxxxxxxxxxxxxxxxxxxxxx",
+ expectTokenInfo: true,
+ },
+ {
+ name: "fine-grained personal access token without Bearer prefix",
+ authHeader: "github_pat_xxxxxxxxxxxxxxxxxxxxxxx",
+ expectedStatusCode: http.StatusOK,
+ expectedTokenType: utils.TokenTypeFineGrainedPersonalAccessToken,
+ expectedToken: "github_pat_xxxxxxxxxxxxxxxxxxxxxxx",
+ expectTokenInfo: true,
+ },
+ // OAuth Access Token - gho_ prefix
+ {
+ name: "OAuth access token with Bearer prefix",
+ authHeader: "Bearer gho_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
+ expectedStatusCode: http.StatusOK,
+ expectedTokenType: utils.TokenTypeOAuthAccessToken,
+ expectedToken: "gho_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
+ expectTokenInfo: true,
+ },
+ {
+ name: "OAuth access token without Bearer prefix",
+ authHeader: "gho_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
+ expectedStatusCode: http.StatusOK,
+ expectedTokenType: utils.TokenTypeOAuthAccessToken,
+ expectedToken: "gho_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
+ expectTokenInfo: true,
+ },
+ // User-to-Server GitHub App Token - ghu_ prefix
+ {
+ name: "user-to-server GitHub App token with Bearer prefix",
+ authHeader: "Bearer ghu_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
+ expectedStatusCode: http.StatusOK,
+ expectedTokenType: utils.TokenTypeUserToServerGitHubAppToken,
+ expectedToken: "ghu_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
+ expectTokenInfo: true,
+ },
+ {
+ name: "user-to-server GitHub App token without Bearer prefix",
+ authHeader: "ghu_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
+ expectedStatusCode: http.StatusOK,
+ expectedTokenType: utils.TokenTypeUserToServerGitHubAppToken,
+ expectedToken: "ghu_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
+ expectTokenInfo: true,
+ },
+ // Server-to-Server GitHub App Token (installation token) - ghs_ prefix
+ {
+ name: "server-to-server GitHub App token with Bearer prefix",
+ authHeader: "Bearer ghs_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
+ expectedStatusCode: http.StatusOK,
+ expectedTokenType: utils.TokenTypeServerToServerGitHubAppToken,
+ expectedToken: "ghs_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
+ expectTokenInfo: true,
+ },
+ {
+ name: "server-to-server GitHub App token without Bearer prefix",
+ authHeader: "ghs_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
+ expectedStatusCode: http.StatusOK,
+ expectedTokenType: utils.TokenTypeServerToServerGitHubAppToken,
+ expectedToken: "ghs_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
+ expectTokenInfo: true,
+ },
+ // Old-style Personal Access Token (40 hex characters, pre-2021)
+ {
+ name: "old-style personal access token (40 hex chars) with Bearer prefix",
+ authHeader: "Bearer 0123456789abcdef0123456789abcdef01234567",
+ expectedStatusCode: http.StatusOK,
+ expectedTokenType: utils.TokenTypePersonalAccessToken,
+ expectedToken: "0123456789abcdef0123456789abcdef01234567",
+ expectTokenInfo: true,
+ },
+ {
+ name: "old-style personal access token (40 hex chars) without Bearer prefix",
+ authHeader: "0123456789abcdef0123456789abcdef01234567",
+ expectedStatusCode: http.StatusOK,
+ expectedTokenType: utils.TokenTypePersonalAccessToken,
+ expectedToken: "0123456789abcdef0123456789abcdef01234567",
+ expectTokenInfo: true,
+ },
+ // Error cases
+ {
+ name: "unsupported GitHub-Bearer header returns 400",
+ authHeader: "GitHub-Bearer some_encrypted_token",
+ expectedStatusCode: http.StatusBadRequest,
+ expectTokenInfo: false,
+ },
+ {
+ name: "invalid token format returns 400",
+ authHeader: "Bearer invalid_token_format",
+ expectedStatusCode: http.StatusBadRequest,
+ expectTokenInfo: false,
+ },
+ {
+ name: "unrecognized prefix returns 400",
+ authHeader: "Bearer xyz_notavalidprefix",
+ expectedStatusCode: http.StatusBadRequest,
+ expectTokenInfo: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ var capturedTokenInfo *ghcontext.TokenInfo
+ var tokenInfoCaptured bool
+
+ nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ capturedTokenInfo, tokenInfoCaptured = ghcontext.GetTokenInfo(r.Context())
+ w.WriteHeader(http.StatusOK)
+ })
+
+ middleware := ExtractUserToken(oauthCfg)
+ handler := middleware(nextHandler)
+
+ req := httptest.NewRequest(http.MethodGet, "/test", nil)
+ if tt.authHeader != "" {
+ req.Header.Set(headers.AuthorizationHeader, tt.authHeader)
+ }
+ rr := httptest.NewRecorder()
+
+ handler.ServeHTTP(rr, req)
+
+ assert.Equal(t, tt.expectedStatusCode, rr.Code)
+
+ if tt.expectWWWAuth {
+ wwwAuth := rr.Header().Get("WWW-Authenticate")
+ assert.NotEmpty(t, wwwAuth, "expected WWW-Authenticate header")
+ assert.Contains(t, wwwAuth, "Bearer resource_metadata=")
+ }
+
+ if tt.expectTokenInfo {
+ require.True(t, tokenInfoCaptured, "expected TokenInfo to be present in context")
+ require.NotNil(t, capturedTokenInfo)
+ assert.Equal(t, tt.expectedTokenType, capturedTokenInfo.TokenType)
+ assert.Equal(t, tt.expectedToken, capturedTokenInfo.Token)
+ } else {
+ assert.False(t, tokenInfoCaptured, "expected no TokenInfo in context")
+ }
+ })
+ }
+}
+
+func TestExtractUserToken_NilOAuthConfig(t *testing.T) {
+ var capturedTokenInfo *ghcontext.TokenInfo
+ var tokenInfoCaptured bool
+
+ nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ capturedTokenInfo, tokenInfoCaptured = ghcontext.GetTokenInfo(r.Context())
+ w.WriteHeader(http.StatusOK)
+ })
+
+ middleware := ExtractUserToken(nil)
+ handler := middleware(nextHandler)
+
+ req := httptest.NewRequest(http.MethodGet, "/test", nil)
+ req.Header.Set(headers.AuthorizationHeader, "Bearer ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx")
+ rr := httptest.NewRecorder()
+
+ handler.ServeHTTP(rr, req)
+
+ assert.Equal(t, http.StatusOK, rr.Code)
+ require.True(t, tokenInfoCaptured)
+ require.NotNil(t, capturedTokenInfo)
+ assert.Equal(t, utils.TokenTypePersonalAccessToken, capturedTokenInfo.TokenType)
+}
+
+func TestExtractUserToken_MissingAuthHeader_WWWAuthenticateFormat(t *testing.T) {
+ oauthCfg := &oauth.Config{
+ BaseURL: "https://api.example.com",
+ AuthorizationServer: "https://github.com/login/oauth",
+ ResourcePath: "/mcp",
+ }
+
+ nextHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ })
+
+ middleware := ExtractUserToken(oauthCfg)
+ handler := middleware(nextHandler)
+
+ req := httptest.NewRequest(http.MethodGet, "/test", nil)
+ // No Authorization header
+ rr := httptest.NewRecorder()
+
+ handler.ServeHTTP(rr, req)
+
+ assert.Equal(t, http.StatusUnauthorized, rr.Code)
+ wwwAuth := rr.Header().Get("WWW-Authenticate")
+ assert.NotEmpty(t, wwwAuth)
+ assert.Contains(t, wwwAuth, "Bearer")
+ assert.Contains(t, wwwAuth, "resource_metadata=")
+ assert.Contains(t, wwwAuth, "/.well-known/oauth-protected-resource")
+}
+
+func TestSendAuthChallenge(t *testing.T) {
+ tests := []struct {
+ name string
+ oauthCfg *oauth.Config
+ requestPath string
+ expectedContains []string
+ }{
+ {
+ name: "with base URL configured",
+ oauthCfg: &oauth.Config{
+ BaseURL: "https://mcp.example.com",
+ },
+ requestPath: "/api/test",
+ expectedContains: []string{
+ "Bearer",
+ "resource_metadata=",
+ "https://mcp.example.com/.well-known/oauth-protected-resource",
+ },
+ },
+ {
+ name: "with nil config uses request host",
+ oauthCfg: nil,
+ requestPath: "/api/test",
+ expectedContains: []string{
+ "Bearer",
+ "resource_metadata=",
+ "/.well-known/oauth-protected-resource",
+ },
+ },
+ {
+ name: "with resource path configured",
+ oauthCfg: &oauth.Config{
+ BaseURL: "https://mcp.example.com",
+ ResourcePath: "/mcp",
+ },
+ requestPath: "/api/test",
+ expectedContains: []string{
+ "Bearer",
+ "resource_metadata=",
+ "/mcp",
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ rr := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, tt.requestPath, nil)
+
+ sendAuthChallenge(rr, req, tt.oauthCfg)
+
+ assert.Equal(t, http.StatusUnauthorized, rr.Code)
+ wwwAuth := rr.Header().Get("WWW-Authenticate")
+ for _, expected := range tt.expectedContains {
+ assert.Contains(t, wwwAuth, expected)
+ }
+ })
+ }
+}
diff --git a/pkg/http/oauth/oauth.go b/pkg/http/oauth/oauth.go
new file mode 100644
index 000000000..ecdcf95ab
--- /dev/null
+++ b/pkg/http/oauth/oauth.go
@@ -0,0 +1,243 @@
+// Package oauth provides OAuth 2.0 Protected Resource Metadata (RFC 9728) support
+// for the GitHub MCP Server HTTP mode.
+package oauth
+
+import (
+ "fmt"
+ "net/http"
+ "strings"
+
+ "github.com/github/github-mcp-server/pkg/http/headers"
+ "github.com/go-chi/chi/v5"
+ "github.com/modelcontextprotocol/go-sdk/auth"
+ "github.com/modelcontextprotocol/go-sdk/oauthex"
+)
+
+const (
+ // OAuthProtectedResourcePrefix is the well-known path prefix for OAuth protected resource metadata.
+ OAuthProtectedResourcePrefix = "/.well-known/oauth-protected-resource"
+
+ // DefaultAuthorizationServer is GitHub's OAuth authorization server.
+ DefaultAuthorizationServer = "https://github.com/login/oauth"
+)
+
+// SupportedScopes lists all OAuth scopes that may be required by MCP tools.
+var SupportedScopes = []string{
+ "repo",
+ "read:org",
+ "read:user",
+ "user:email",
+ "read:packages",
+ "write:packages",
+ "read:project",
+ "project",
+ "gist",
+ "notifications",
+ "workflow",
+ "codespace",
+}
+
+// Config holds the OAuth configuration for the MCP server.
+type Config struct {
+ // BaseURL is the publicly accessible URL where this server is hosted.
+ // This is used to construct the OAuth resource URL.
+ BaseURL string
+
+ // AuthorizationServer is the OAuth authorization server URL.
+ // Defaults to GitHub's OAuth server if not specified.
+ AuthorizationServer string
+
+ // ResourcePath is the externally visible base path for the MCP server (e.g., "/mcp").
+ // This is used to restore the original path when a proxy strips a base path before forwarding.
+ // If empty, requests are treated as already using the external path.
+ ResourcePath string
+}
+
+// AuthHandler handles OAuth-related HTTP endpoints.
+type AuthHandler struct {
+ cfg *Config
+}
+
+// NewAuthHandler creates a new OAuth auth handler.
+func NewAuthHandler(cfg *Config) (*AuthHandler, error) {
+ if cfg == nil {
+ cfg = &Config{}
+ }
+
+ // Default authorization server to GitHub
+ if cfg.AuthorizationServer == "" {
+ cfg.AuthorizationServer = DefaultAuthorizationServer
+ }
+
+ return &AuthHandler{
+ cfg: cfg,
+ }, nil
+}
+
+// routePatterns defines the route patterns for OAuth protected resource metadata.
+var routePatterns = []string{
+ "", // Root: /.well-known/oauth-protected-resource
+ "/readonly", // Read-only mode
+ "/insiders", // Insiders mode
+ "/x/{toolset}",
+ "/x/{toolset}/readonly",
+}
+
+// RegisterRoutes registers the OAuth protected resource metadata routes.
+func (h *AuthHandler) RegisterRoutes(r chi.Router) {
+ for _, pattern := range routePatterns {
+ for _, route := range h.routesForPattern(pattern) {
+ path := OAuthProtectedResourcePrefix + route
+ r.Handle(path, h.metadataHandler())
+ }
+ }
+}
+
+func (h *AuthHandler) metadataHandler() http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ resourcePath := resolveResourcePath(
+ strings.TrimPrefix(r.URL.Path, OAuthProtectedResourcePrefix),
+ h.cfg.ResourcePath,
+ )
+ resourceURL := h.buildResourceURL(r, resourcePath)
+
+ metadata := &oauthex.ProtectedResourceMetadata{
+ Resource: resourceURL,
+ AuthorizationServers: []string{h.cfg.AuthorizationServer},
+ ResourceName: "GitHub MCP Server",
+ ScopesSupported: SupportedScopes,
+ BearerMethodsSupported: []string{"header"},
+ }
+
+ auth.ProtectedResourceMetadataHandler(metadata).ServeHTTP(w, r)
+ })
+}
+
+// routesForPattern generates route variants for a given pattern.
+// GitHub strips the /mcp prefix before forwarding, so we register both variants:
+// - With /mcp prefix: for direct access or when GitHub doesn't strip
+// - Without /mcp prefix: for when GitHub has stripped the prefix
+func (h *AuthHandler) routesForPattern(pattern string) []string {
+ basePaths := []string{""}
+ if basePath := normalizeBasePath(h.cfg.ResourcePath); basePath != "" {
+ basePaths = append(basePaths, basePath)
+ } else {
+ basePaths = append(basePaths, "/mcp")
+ }
+
+ routes := make([]string, 0, len(basePaths)*2)
+ for _, basePath := range basePaths {
+ routes = append(routes, joinRoute(basePath, pattern))
+ routes = append(routes, joinRoute(basePath, pattern)+"/")
+ }
+
+ return routes
+}
+
+// resolveResourcePath returns the externally visible resource path,
+// restoring the configured base path when proxies strip it before forwarding.
+func resolveResourcePath(path, basePath string) string {
+ if path == "" {
+ path = "/"
+ }
+ base := normalizeBasePath(basePath)
+ if base == "" {
+ return path
+ }
+ if path == "/" {
+ return base
+ }
+ if path == base || strings.HasPrefix(path, base+"/") {
+ return path
+ }
+ return base + path
+}
+
+// ResolveResourcePath returns the externally visible resource path for a request.
+// Exported for use by middleware.
+func ResolveResourcePath(r *http.Request, cfg *Config) string {
+ basePath := ""
+ if cfg != nil {
+ basePath = cfg.ResourcePath
+ }
+ return resolveResourcePath(r.URL.Path, basePath)
+}
+
+// buildResourceURL constructs the full resource URL for OAuth metadata.
+func (h *AuthHandler) buildResourceURL(r *http.Request, resourcePath string) string {
+ host, scheme := GetEffectiveHostAndScheme(r, h.cfg)
+ baseURL := fmt.Sprintf("%s://%s", scheme, host)
+ if h.cfg.BaseURL != "" {
+ baseURL = strings.TrimSuffix(h.cfg.BaseURL, "/")
+ }
+ if resourcePath == "" {
+ resourcePath = "/"
+ }
+ if !strings.HasPrefix(resourcePath, "/") {
+ resourcePath = "/" + resourcePath
+ }
+ return baseURL + resourcePath
+}
+
+// GetEffectiveHostAndScheme returns the effective host and scheme for a request.
+func GetEffectiveHostAndScheme(r *http.Request, cfg *Config) (host, scheme string) { //nolint:revive
+ if fh := r.Header.Get(headers.ForwardedHostHeader); fh != "" {
+ host = fh
+ } else {
+ host = r.Host
+ }
+ if host == "" {
+ host = "localhost"
+ }
+ if fp := r.Header.Get(headers.ForwardedProtoHeader); fp != "" {
+ scheme = strings.ToLower(fp)
+ } else {
+ if r.TLS != nil {
+ scheme = "https"
+ } else {
+ scheme = "http"
+ }
+ }
+ return
+}
+
+// BuildResourceMetadataURL constructs the full URL to the OAuth protected resource metadata endpoint.
+func BuildResourceMetadataURL(r *http.Request, cfg *Config, resourcePath string) string {
+ host, scheme := GetEffectiveHostAndScheme(r, cfg)
+ suffix := ""
+ if resourcePath != "" && resourcePath != "/" {
+ if !strings.HasPrefix(resourcePath, "/") {
+ suffix = "/" + resourcePath
+ } else {
+ suffix = resourcePath
+ }
+ }
+ if cfg != nil && cfg.BaseURL != "" {
+ return strings.TrimSuffix(cfg.BaseURL, "/") + OAuthProtectedResourcePrefix + suffix
+ }
+ return fmt.Sprintf("%s://%s%s%s", scheme, host, OAuthProtectedResourcePrefix, suffix)
+}
+
+func normalizeBasePath(path string) string {
+ trimmed := strings.TrimSpace(path)
+ if trimmed == "" || trimmed == "/" {
+ return ""
+ }
+ if !strings.HasPrefix(trimmed, "/") {
+ trimmed = "/" + trimmed
+ }
+ return strings.TrimSuffix(trimmed, "/")
+}
+
+func joinRoute(basePath, pattern string) string {
+ if basePath == "" {
+ return pattern
+ }
+ if pattern == "" {
+ return basePath
+ }
+ if strings.HasSuffix(basePath, "/") {
+ return strings.TrimSuffix(basePath, "/") + pattern
+ }
+ return basePath + pattern
+}
diff --git a/pkg/http/oauth/oauth_test.go b/pkg/http/oauth/oauth_test.go
new file mode 100644
index 000000000..9133e8331
--- /dev/null
+++ b/pkg/http/oauth/oauth_test.go
@@ -0,0 +1,615 @@
+package oauth
+
+import (
+ "crypto/tls"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/github/github-mcp-server/pkg/http/headers"
+ "github.com/go-chi/chi/v5"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestNewAuthHandler(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ cfg *Config
+ expectedAuthServer string
+ expectedResourcePath string
+ }{
+ {
+ name: "nil config uses defaults",
+ cfg: nil,
+ expectedAuthServer: DefaultAuthorizationServer,
+ expectedResourcePath: "",
+ },
+ {
+ name: "empty config uses defaults",
+ cfg: &Config{},
+ expectedAuthServer: DefaultAuthorizationServer,
+ expectedResourcePath: "",
+ },
+ {
+ name: "custom authorization server",
+ cfg: &Config{
+ AuthorizationServer: "https://custom.example.com/oauth",
+ },
+ expectedAuthServer: "https://custom.example.com/oauth",
+ expectedResourcePath: "",
+ },
+ {
+ name: "custom base URL and resource path",
+ cfg: &Config{
+ BaseURL: "https://example.com",
+ ResourcePath: "/mcp",
+ },
+ expectedAuthServer: DefaultAuthorizationServer,
+ expectedResourcePath: "/mcp",
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ handler, err := NewAuthHandler(tc.cfg)
+ require.NoError(t, err)
+ require.NotNil(t, handler)
+
+ assert.Equal(t, tc.expectedAuthServer, handler.cfg.AuthorizationServer)
+ })
+ }
+}
+
+func TestGetEffectiveHostAndScheme(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ setupRequest func() *http.Request
+ cfg *Config
+ expectedHost string
+ expectedScheme string
+ }{
+ {
+ name: "basic request without forwarding headers",
+ setupRequest: func() *http.Request {
+ req := httptest.NewRequest(http.MethodGet, "/test", nil)
+ req.Host = "example.com"
+ return req
+ },
+ cfg: &Config{},
+ expectedHost: "example.com",
+ expectedScheme: "http", // defaults to http
+ },
+ {
+ name: "request with X-Forwarded-Host header",
+ setupRequest: func() *http.Request {
+ req := httptest.NewRequest(http.MethodGet, "/test", nil)
+ req.Host = "internal.example.com"
+ req.Header.Set(headers.ForwardedHostHeader, "public.example.com")
+ return req
+ },
+ cfg: &Config{},
+ expectedHost: "public.example.com",
+ expectedScheme: "http",
+ },
+ {
+ name: "request with X-Forwarded-Proto header",
+ setupRequest: func() *http.Request {
+ req := httptest.NewRequest(http.MethodGet, "/test", nil)
+ req.Host = "example.com"
+ req.Header.Set(headers.ForwardedProtoHeader, "http")
+ return req
+ },
+ cfg: &Config{},
+ expectedHost: "example.com",
+ expectedScheme: "http",
+ },
+ {
+ name: "request with both forwarding headers",
+ setupRequest: func() *http.Request {
+ req := httptest.NewRequest(http.MethodGet, "/test", nil)
+ req.Host = "internal.example.com"
+ req.Header.Set(headers.ForwardedHostHeader, "public.example.com")
+ req.Header.Set(headers.ForwardedProtoHeader, "https")
+ return req
+ },
+ cfg: &Config{},
+ expectedHost: "public.example.com",
+ expectedScheme: "https",
+ },
+ {
+ name: "request with TLS",
+ setupRequest: func() *http.Request {
+ req := httptest.NewRequest(http.MethodGet, "/test", nil)
+ req.Host = "example.com"
+ req.TLS = &tls.ConnectionState{}
+ return req
+ },
+ cfg: &Config{},
+ expectedHost: "example.com",
+ expectedScheme: "https",
+ },
+ {
+ name: "X-Forwarded-Proto takes precedence over TLS",
+ setupRequest: func() *http.Request {
+ req := httptest.NewRequest(http.MethodGet, "/test", nil)
+ req.Host = "example.com"
+ req.TLS = &tls.ConnectionState{}
+ req.Header.Set(headers.ForwardedProtoHeader, "http")
+ return req
+ },
+ cfg: &Config{},
+ expectedHost: "example.com",
+ expectedScheme: "http",
+ },
+ {
+ name: "scheme is lowercased",
+ setupRequest: func() *http.Request {
+ req := httptest.NewRequest(http.MethodGet, "/test", nil)
+ req.Host = "example.com"
+ req.Header.Set(headers.ForwardedProtoHeader, "HTTPS")
+ return req
+ },
+ cfg: &Config{},
+ expectedHost: "example.com",
+ expectedScheme: "https",
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ req := tc.setupRequest()
+ host, scheme := GetEffectiveHostAndScheme(req, tc.cfg)
+
+ assert.Equal(t, tc.expectedHost, host)
+ assert.Equal(t, tc.expectedScheme, scheme)
+ })
+ }
+}
+
+func TestResolveResourcePath(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ cfg *Config
+ setupRequest func() *http.Request
+ expectedPath string
+ }{
+ {
+ name: "no base path uses request path",
+ cfg: &Config{},
+ setupRequest: func() *http.Request {
+ return httptest.NewRequest(http.MethodGet, "/x/repos", nil)
+ },
+ expectedPath: "/x/repos",
+ },
+ {
+ name: "base path restored for root",
+ cfg: &Config{
+ ResourcePath: "/mcp",
+ },
+ setupRequest: func() *http.Request {
+ return httptest.NewRequest(http.MethodGet, "/", nil)
+ },
+ expectedPath: "/mcp",
+ },
+ {
+ name: "base path restored for nested",
+ cfg: &Config{
+ ResourcePath: "/mcp",
+ },
+ setupRequest: func() *http.Request {
+ return httptest.NewRequest(http.MethodGet, "/readonly", nil)
+ },
+ expectedPath: "/mcp/readonly",
+ },
+ {
+ name: "base path preserved when already present",
+ cfg: &Config{
+ ResourcePath: "/mcp",
+ },
+ setupRequest: func() *http.Request {
+ return httptest.NewRequest(http.MethodGet, "/mcp/readonly/", nil)
+ },
+ expectedPath: "/mcp/readonly/",
+ },
+ {
+ name: "custom base path restored",
+ cfg: &Config{
+ ResourcePath: "/api",
+ },
+ setupRequest: func() *http.Request {
+ return httptest.NewRequest(http.MethodGet, "/x/repos", nil)
+ },
+ expectedPath: "/api/x/repos",
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ req := tc.setupRequest()
+ path := ResolveResourcePath(req, tc.cfg)
+
+ assert.Equal(t, tc.expectedPath, path)
+ })
+ }
+}
+
+func TestBuildResourceMetadataURL(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ cfg *Config
+ setupRequest func() *http.Request
+ resourcePath string
+ expectedURL string
+ }{
+ {
+ name: "root path",
+ cfg: &Config{},
+ setupRequest: func() *http.Request {
+ req := httptest.NewRequest(http.MethodGet, "/", nil)
+ req.Host = "api.example.com"
+ return req
+ },
+ resourcePath: "/",
+ expectedURL: "http://api.example.com/.well-known/oauth-protected-resource",
+ },
+ {
+ name: "resource path preserves trailing slash",
+ cfg: &Config{},
+ setupRequest: func() *http.Request {
+ req := httptest.NewRequest(http.MethodGet, "/mcp/", nil)
+ req.Host = "api.example.com"
+ return req
+ },
+ resourcePath: "/mcp/",
+ expectedURL: "http://api.example.com/.well-known/oauth-protected-resource/mcp/",
+ },
+ {
+ name: "with custom resource path",
+ cfg: &Config{},
+ setupRequest: func() *http.Request {
+ req := httptest.NewRequest(http.MethodGet, "/mcp", nil)
+ req.Host = "api.example.com"
+ return req
+ },
+ resourcePath: "/mcp",
+ expectedURL: "http://api.example.com/.well-known/oauth-protected-resource/mcp",
+ },
+ {
+ name: "with base URL config",
+ cfg: &Config{
+ BaseURL: "https://custom.example.com",
+ },
+ setupRequest: func() *http.Request {
+ req := httptest.NewRequest(http.MethodGet, "/mcp", nil)
+ req.Host = "api.example.com"
+ return req
+ },
+ resourcePath: "/mcp",
+ expectedURL: "https://custom.example.com/.well-known/oauth-protected-resource/mcp",
+ },
+ {
+ name: "with forwarded headers",
+ cfg: &Config{},
+ setupRequest: func() *http.Request {
+ req := httptest.NewRequest(http.MethodGet, "/mcp", nil)
+ req.Host = "internal.example.com"
+ req.Header.Set(headers.ForwardedHostHeader, "public.example.com")
+ req.Header.Set(headers.ForwardedProtoHeader, "https")
+ return req
+ },
+ resourcePath: "/mcp",
+ expectedURL: "https://public.example.com/.well-known/oauth-protected-resource/mcp",
+ },
+ {
+ name: "nil config uses request host",
+ cfg: nil,
+ setupRequest: func() *http.Request {
+ req := httptest.NewRequest(http.MethodGet, "/", nil)
+ req.Host = "api.example.com"
+ return req
+ },
+ resourcePath: "",
+ expectedURL: "http://api.example.com/.well-known/oauth-protected-resource",
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ req := tc.setupRequest()
+ url := BuildResourceMetadataURL(req, tc.cfg, tc.resourcePath)
+
+ assert.Equal(t, tc.expectedURL, url)
+ })
+ }
+}
+
+func TestHandleProtectedResource(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ cfg *Config
+ path string
+ host string
+ method string
+ expectedStatusCode int
+ expectedScopes []string
+ validateResponse func(t *testing.T, body map[string]any)
+ }{
+ {
+ name: "GET request returns protected resource metadata",
+ cfg: &Config{
+ BaseURL: "https://api.example.com",
+ },
+ path: OAuthProtectedResourcePrefix,
+ host: "api.example.com",
+ method: http.MethodGet,
+ expectedStatusCode: http.StatusOK,
+ expectedScopes: SupportedScopes,
+ validateResponse: func(t *testing.T, body map[string]any) {
+ t.Helper()
+ assert.Equal(t, "GitHub MCP Server", body["resource_name"])
+ assert.Equal(t, "https://api.example.com/", body["resource"])
+
+ authServers, ok := body["authorization_servers"].([]any)
+ require.True(t, ok)
+ require.Len(t, authServers, 1)
+ assert.Equal(t, DefaultAuthorizationServer, authServers[0])
+ },
+ },
+ {
+ name: "OPTIONS request for CORS preflight",
+ cfg: &Config{
+ BaseURL: "https://api.example.com",
+ },
+ path: OAuthProtectedResourcePrefix,
+ host: "api.example.com",
+ method: http.MethodOptions,
+ expectedStatusCode: http.StatusNoContent,
+ },
+ {
+ name: "path with /mcp suffix",
+ cfg: &Config{
+ BaseURL: "https://api.example.com",
+ },
+ path: OAuthProtectedResourcePrefix + "/mcp",
+ host: "api.example.com",
+ method: http.MethodGet,
+ expectedStatusCode: http.StatusOK,
+ validateResponse: func(t *testing.T, body map[string]any) {
+ t.Helper()
+ assert.Equal(t, "https://api.example.com/mcp", body["resource"])
+ },
+ },
+ {
+ name: "path with /readonly suffix",
+ cfg: &Config{
+ BaseURL: "https://api.example.com",
+ },
+ path: OAuthProtectedResourcePrefix + "/readonly",
+ host: "api.example.com",
+ method: http.MethodGet,
+ expectedStatusCode: http.StatusOK,
+ validateResponse: func(t *testing.T, body map[string]any) {
+ t.Helper()
+ assert.Equal(t, "https://api.example.com/readonly", body["resource"])
+ },
+ },
+ {
+ name: "path with trailing slash",
+ cfg: &Config{
+ BaseURL: "https://api.example.com",
+ },
+ path: OAuthProtectedResourcePrefix + "/mcp/",
+ host: "api.example.com",
+ method: http.MethodGet,
+ expectedStatusCode: http.StatusOK,
+ validateResponse: func(t *testing.T, body map[string]any) {
+ t.Helper()
+ assert.Equal(t, "https://api.example.com/mcp/", body["resource"])
+ },
+ },
+ {
+ name: "custom authorization server in response",
+ cfg: &Config{
+ BaseURL: "https://api.example.com",
+ AuthorizationServer: "https://custom.auth.example.com/oauth",
+ },
+ path: OAuthProtectedResourcePrefix,
+ host: "api.example.com",
+ method: http.MethodGet,
+ expectedStatusCode: http.StatusOK,
+ validateResponse: func(t *testing.T, body map[string]any) {
+ t.Helper()
+ authServers, ok := body["authorization_servers"].([]any)
+ require.True(t, ok)
+ require.Len(t, authServers, 1)
+ assert.Equal(t, "https://custom.auth.example.com/oauth", authServers[0])
+ },
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ handler, err := NewAuthHandler(tc.cfg)
+ require.NoError(t, err)
+
+ router := chi.NewRouter()
+ handler.RegisterRoutes(router)
+
+ req := httptest.NewRequest(tc.method, tc.path, nil)
+ req.Host = tc.host
+
+ rec := httptest.NewRecorder()
+ router.ServeHTTP(rec, req)
+
+ assert.Equal(t, tc.expectedStatusCode, rec.Code)
+
+ // Check CORS headers
+ assert.Equal(t, "*", rec.Header().Get("Access-Control-Allow-Origin"))
+ assert.Contains(t, rec.Header().Get("Access-Control-Allow-Methods"), "GET")
+ assert.Contains(t, rec.Header().Get("Access-Control-Allow-Methods"), "OPTIONS")
+
+ if tc.method == http.MethodGet && tc.validateResponse != nil {
+ assert.Equal(t, "application/json", rec.Header().Get("Content-Type"))
+
+ var body map[string]any
+ err := json.Unmarshal(rec.Body.Bytes(), &body)
+ require.NoError(t, err)
+
+ tc.validateResponse(t, body)
+
+ // Verify scopes if expected
+ if tc.expectedScopes != nil {
+ scopes, ok := body["scopes_supported"].([]any)
+ require.True(t, ok)
+ assert.Len(t, scopes, len(tc.expectedScopes))
+ }
+ }
+ })
+ }
+}
+
+func TestRegisterRoutes(t *testing.T) {
+ t.Parallel()
+
+ handler, err := NewAuthHandler(&Config{
+ BaseURL: "https://api.example.com",
+ })
+ require.NoError(t, err)
+
+ router := chi.NewRouter()
+ handler.RegisterRoutes(router)
+
+ // List of expected routes that should be registered
+ expectedRoutes := []string{
+ OAuthProtectedResourcePrefix,
+ OAuthProtectedResourcePrefix + "/",
+ OAuthProtectedResourcePrefix + "/mcp",
+ OAuthProtectedResourcePrefix + "/mcp/",
+ OAuthProtectedResourcePrefix + "/readonly",
+ OAuthProtectedResourcePrefix + "/readonly/",
+ OAuthProtectedResourcePrefix + "/mcp/readonly",
+ OAuthProtectedResourcePrefix + "/mcp/readonly/",
+ OAuthProtectedResourcePrefix + "/x/repos",
+ OAuthProtectedResourcePrefix + "/mcp/x/repos",
+ }
+
+ for _, route := range expectedRoutes {
+ t.Run("route:"+route, func(t *testing.T) {
+ // Test GET
+ req := httptest.NewRequest(http.MethodGet, route, nil)
+ req.Host = "api.example.com"
+ rec := httptest.NewRecorder()
+ router.ServeHTTP(rec, req)
+ assert.Equal(t, http.StatusOK, rec.Code, "GET %s should return 200", route)
+
+ // Test OPTIONS (CORS preflight)
+ req = httptest.NewRequest(http.MethodOptions, route, nil)
+ req.Host = "api.example.com"
+ rec = httptest.NewRecorder()
+ router.ServeHTTP(rec, req)
+ assert.Equal(t, http.StatusNoContent, rec.Code, "OPTIONS %s should return 204", route)
+ })
+ }
+}
+
+func TestSupportedScopes(t *testing.T) {
+ t.Parallel()
+
+ // Verify all expected scopes are present
+ expectedScopes := []string{
+ "repo",
+ "read:org",
+ "read:user",
+ "user:email",
+ "read:packages",
+ "write:packages",
+ "read:project",
+ "project",
+ "gist",
+ "notifications",
+ "workflow",
+ "codespace",
+ }
+
+ assert.Equal(t, expectedScopes, SupportedScopes)
+}
+
+func TestProtectedResourceResponseFormat(t *testing.T) {
+ t.Parallel()
+
+ handler, err := NewAuthHandler(&Config{
+ BaseURL: "https://api.example.com",
+ })
+ require.NoError(t, err)
+
+ router := chi.NewRouter()
+ handler.RegisterRoutes(router)
+
+ req := httptest.NewRequest(http.MethodGet, OAuthProtectedResourcePrefix, nil)
+ req.Host = "api.example.com"
+
+ rec := httptest.NewRecorder()
+ router.ServeHTTP(rec, req)
+
+ require.Equal(t, http.StatusOK, rec.Code)
+
+ var response map[string]any
+ err = json.Unmarshal(rec.Body.Bytes(), &response)
+ require.NoError(t, err)
+
+ // Verify all required RFC 9728 fields are present
+ assert.Contains(t, response, "resource")
+ assert.Contains(t, response, "authorization_servers")
+ assert.Contains(t, response, "bearer_methods_supported")
+ assert.Contains(t, response, "scopes_supported")
+
+ // Verify resource name (optional but we include it)
+ assert.Contains(t, response, "resource_name")
+ assert.Equal(t, "GitHub MCP Server", response["resource_name"])
+
+ // Verify bearer_methods_supported contains "header"
+ bearerMethods, ok := response["bearer_methods_supported"].([]any)
+ require.True(t, ok)
+ assert.Contains(t, bearerMethods, "header")
+
+ // Verify authorization_servers is an array with GitHub OAuth
+ authServers, ok := response["authorization_servers"].([]any)
+ require.True(t, ok)
+ assert.Len(t, authServers, 1)
+ assert.Equal(t, DefaultAuthorizationServer, authServers[0])
+}
+
+func TestOAuthProtectedResourcePrefix(t *testing.T) {
+ t.Parallel()
+
+ // RFC 9728 specifies this well-known path
+ assert.Equal(t, "/.well-known/oauth-protected-resource", OAuthProtectedResourcePrefix)
+}
+
+func TestDefaultAuthorizationServer(t *testing.T) {
+ t.Parallel()
+
+ assert.Equal(t, "https://github.com/login/oauth", DefaultAuthorizationServer)
+}
diff --git a/pkg/http/server.go b/pkg/http/server.go
new file mode 100644
index 000000000..7a7ab46de
--- /dev/null
+++ b/pkg/http/server.go
@@ -0,0 +1,224 @@
+package http
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "log/slog"
+ "net/http"
+ "os"
+ "os/signal"
+ "slices"
+ "syscall"
+ "time"
+
+ ghcontext "github.com/github/github-mcp-server/pkg/context"
+ "github.com/github/github-mcp-server/pkg/github"
+ "github.com/github/github-mcp-server/pkg/http/oauth"
+ "github.com/github/github-mcp-server/pkg/inventory"
+ "github.com/github/github-mcp-server/pkg/lockdown"
+ "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-chi/chi/v5"
+)
+
+// knownFeatureFlags are the feature flags that can be enabled via X-MCP-Features header.
+// Only these flags are accepted from headers.
+var knownFeatureFlags = []string{
+ github.FeatureFlagHoldbackConsolidatedProjects,
+ github.FeatureFlagHoldbackConsolidatedActions,
+}
+
+type ServerConfig struct {
+ // Version of the server
+ Version string
+
+ // GitHub Host to target for API requests (e.g. github.com or github.enterprise.com)
+ Host string
+
+ // Port to listen on (default: 8082)
+ Port int
+
+ // BaseURL is the publicly accessible URL of this server for OAuth resource metadata.
+ // If not set, the server will derive the URL from incoming request headers.
+ BaseURL string
+
+ // ResourcePath is the externally visible base path for this server (e.g., "/mcp").
+ // This is used to restore the original path when a proxy strips a base path before forwarding.
+ ResourcePath string
+
+ // ExportTranslations indicates if we should export translations
+ // See: https://github.com/github/github-mcp-server?tab=readme-ov-file#i18n--overriding-descriptions
+ ExportTranslations bool
+
+ // EnableCommandLogging indicates if we should log commands
+ EnableCommandLogging bool
+
+ // Path to the log file if not stderr
+ LogFilePath string
+
+ // Content window size
+ ContentWindowSize int
+
+ // LockdownMode indicates if we should enable lockdown mode
+ LockdownMode bool
+
+ // RepoAccessCacheTTL overrides the default TTL for repository access cache entries.
+ RepoAccessCacheTTL *time.Duration
+
+ // ScopeChallenge indicates if we should return OAuth scope challenges, and if we should perform
+ // tool filtering based on token scopes.
+ ScopeChallenge bool
+}
+
+func RunHTTPServer(cfg ServerConfig) error {
+ // Create app context
+ ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
+ defer stop()
+
+ t, dumpTranslations := translations.TranslationHelper()
+
+ var slogHandler slog.Handler
+ var logOutput io.Writer
+ if cfg.LogFilePath != "" {
+ file, err := os.OpenFile(cfg.LogFilePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600)
+ if err != nil {
+ return fmt.Errorf("failed to open log file: %w", err)
+ }
+ logOutput = file
+ slogHandler = slog.NewTextHandler(logOutput, &slog.HandlerOptions{Level: slog.LevelDebug})
+ } else {
+ logOutput = os.Stderr
+ slogHandler = slog.NewTextHandler(logOutput, &slog.HandlerOptions{Level: slog.LevelInfo})
+ }
+ logger := slog.New(slogHandler)
+ logger.Info("starting server", "version", cfg.Version, "host", cfg.Host, "lockdownEnabled", cfg.LockdownMode)
+
+ apiHost, err := utils.NewAPIHost(cfg.Host)
+ if err != nil {
+ return fmt.Errorf("failed to parse API host: %w", err)
+ }
+
+ repoAccessOpts := []lockdown.RepoAccessOption{
+ lockdown.WithLogger(logger.With("component", "lockdown")),
+ }
+ if cfg.RepoAccessCacheTTL != nil {
+ repoAccessOpts = append(repoAccessOpts, lockdown.WithTTL(*cfg.RepoAccessCacheTTL))
+ }
+
+ featureChecker := createHTTPFeatureChecker()
+
+ deps := github.NewRequestDeps(
+ apiHost,
+ cfg.Version,
+ cfg.LockdownMode,
+ repoAccessOpts,
+ t,
+ cfg.ContentWindowSize,
+ featureChecker,
+ )
+
+ // Initialize the global tool scope map
+ err = initGlobalToolScopeMap(t)
+ if err != nil {
+ return fmt.Errorf("failed to initialize tool scope map: %w", err)
+ }
+
+ // Register OAuth protected resource metadata endpoints
+ oauthCfg := &oauth.Config{
+ BaseURL: cfg.BaseURL,
+ ResourcePath: cfg.ResourcePath,
+ }
+
+ serverOptions := []HandlerOption{}
+ if cfg.ScopeChallenge {
+ scopeFetcher := scopes.NewFetcher(apiHost, scopes.FetcherOptions{})
+ serverOptions = append(serverOptions, WithScopeFetcher(scopeFetcher))
+ }
+
+ r := chi.NewRouter()
+ handler := NewHTTPMcpHandler(ctx, &cfg, deps, t, logger, apiHost, append(serverOptions, WithFeatureChecker(featureChecker), WithOAuthConfig(oauthCfg))...)
+ oauthHandler, err := oauth.NewAuthHandler(oauthCfg)
+ if err != nil {
+ return fmt.Errorf("failed to create OAuth handler: %w", err)
+ }
+
+ r.Group(func(r chi.Router) {
+ // Register Middleware First, needs to be before route registration
+ handler.RegisterMiddleware(r)
+
+ // Register MCP server routes
+ handler.RegisterRoutes(r)
+ })
+ logger.Info("MCP endpoints registered", "baseURL", cfg.BaseURL)
+
+ r.Group(func(r chi.Router) {
+ // Register OAuth protected resource metadata endpoints
+ oauthHandler.RegisterRoutes(r)
+ })
+ logger.Info("OAuth protected resource endpoints registered", "baseURL", cfg.BaseURL)
+
+ addr := fmt.Sprintf(":%d", cfg.Port)
+ httpSvr := http.Server{
+ Addr: addr,
+ Handler: r,
+ ReadHeaderTimeout: 60 * time.Second,
+ }
+
+ go func() {
+ <-ctx.Done()
+ shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ logger.Info("shutting down server")
+ if err := httpSvr.Shutdown(shutdownCtx); err != nil {
+ logger.Error("error during server shutdown", "error", err)
+ }
+ }()
+
+ if cfg.ExportTranslations {
+ // Once server is initialized, all translations are loaded
+ dumpTranslations()
+ }
+
+ logger.Info("HTTP server listening", "addr", addr)
+ if err := httpSvr.ListenAndServe(); err != nil && err != http.ErrServerClosed {
+ return fmt.Errorf("HTTP server error: %w", err)
+ }
+
+ logger.Info("server stopped gracefully")
+ return nil
+}
+
+func initGlobalToolScopeMap(t translations.TranslationHelperFunc) error {
+ // Build inventory with all tools to extract scope information
+ inv, err := inventory.NewBuilder().
+ SetTools(github.AllTools(t)).
+ Build()
+
+ if err != nil {
+ return fmt.Errorf("failed to build inventory for tool scope map: %w", err)
+ }
+
+ // Initialize the global scope map
+ scopes.SetToolScopeMapFromInventory(inv)
+
+ return nil
+}
+
+// createHTTPFeatureChecker creates a feature checker that reads header features from context
+// and validates them against the knownFeatureFlags whitelist
+func createHTTPFeatureChecker() inventory.FeatureFlagChecker {
+ // Pre-compute whitelist as set for O(1) lookup
+ knownSet := make(map[string]bool, len(knownFeatureFlags))
+ for _, f := range knownFeatureFlags {
+ knownSet[f] = true
+ }
+
+ return func(ctx context.Context, flag string) (bool, error) {
+ if knownSet[flag] && slices.Contains(ghcontext.GetHeaderFeatures(ctx), flag) {
+ return true, nil
+ }
+ return false, nil
+ }
+}
diff --git a/pkg/http/transport/bearer.go b/pkg/http/transport/bearer.go
new file mode 100644
index 000000000..66922bbda
--- /dev/null
+++ b/pkg/http/transport/bearer.go
@@ -0,0 +1,26 @@
+package transport
+
+import (
+ "net/http"
+ "strings"
+
+ ghcontext "github.com/github/github-mcp-server/pkg/context"
+ headers "github.com/github/github-mcp-server/pkg/http/headers"
+)
+
+type BearerAuthTransport struct {
+ Transport http.RoundTripper
+ Token string
+}
+
+func (t *BearerAuthTransport) RoundTrip(req *http.Request) (*http.Response, error) {
+ req = req.Clone(req.Context())
+ req.Header.Set(headers.AuthorizationHeader, "Bearer "+t.Token)
+
+ // Check for GraphQL-Features in context and add header if present
+ if features := ghcontext.GetGraphQLFeatures(req.Context()); len(features) > 0 {
+ req.Header.Set(headers.GraphQLFeaturesHeader, strings.Join(features, ", "))
+ }
+
+ return t.Transport.RoundTrip(req)
+}
diff --git a/pkg/http/transport/graphql_features.go b/pkg/http/transport/graphql_features.go
new file mode 100644
index 000000000..7fe9182fc
--- /dev/null
+++ b/pkg/http/transport/graphql_features.go
@@ -0,0 +1,52 @@
+package transport
+
+import (
+ "net/http"
+ "strings"
+
+ ghcontext "github.com/github/github-mcp-server/pkg/context"
+ "github.com/github/github-mcp-server/pkg/http/headers"
+)
+
+// GraphQLFeaturesTransport is an http.RoundTripper that adds GraphQL-Features
+// header to requests based on context values. This is required for using
+// non-GA GraphQL API features like the agent assignment API.
+//
+// This transport is used internally by the MCP server and is also exported
+// for library consumers who need to build their own HTTP clients with
+// GraphQL feature flag support.
+//
+// Usage:
+//
+// import "github.com/github/github-mcp-server/pkg/http/transport"
+//
+// httpClient := &http.Client{
+// Transport: &transport.GraphQLFeaturesTransport{
+// Transport: http.DefaultTransport,
+// },
+// }
+// gqlClient := githubv4.NewClient(httpClient)
+//
+// Then use ghcontext.WithGraphQLFeatures(ctx, "feature_name") when calling GraphQL operations.
+type GraphQLFeaturesTransport struct {
+ // Transport is the underlying HTTP transport. If nil, http.DefaultTransport is used.
+ Transport http.RoundTripper
+}
+
+// RoundTrip implements http.RoundTripper.
+func (t *GraphQLFeaturesTransport) RoundTrip(req *http.Request) (*http.Response, error) {
+ transport := t.Transport
+ if transport == nil {
+ transport = http.DefaultTransport
+ }
+
+ // Clone the request to avoid mutating the original
+ req = req.Clone(req.Context())
+
+ // Check for GraphQL-Features in context and add header if present
+ if features := ghcontext.GetGraphQLFeatures(req.Context()); len(features) > 0 {
+ req.Header.Set(headers.GraphQLFeaturesHeader, strings.Join(features, ", "))
+ }
+
+ return transport.RoundTrip(req)
+}
diff --git a/pkg/http/transport/graphql_features_test.go b/pkg/http/transport/graphql_features_test.go
new file mode 100644
index 000000000..1a0dc4214
--- /dev/null
+++ b/pkg/http/transport/graphql_features_test.go
@@ -0,0 +1,154 @@
+package transport
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ ghcontext "github.com/github/github-mcp-server/pkg/context"
+ "github.com/github/github-mcp-server/pkg/http/headers"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestGraphQLFeaturesTransport(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ features []string
+ expectedHeader string
+ hasHeader bool
+ }{
+ {
+ name: "no features in context",
+ features: nil,
+ expectedHeader: "",
+ hasHeader: false,
+ },
+ {
+ name: "single feature in context",
+ features: []string{"issues_copilot_assignment_api_support"},
+ expectedHeader: "issues_copilot_assignment_api_support",
+ hasHeader: true,
+ },
+ {
+ name: "multiple features in context",
+ features: []string{"feature1", "feature2", "feature3"},
+ expectedHeader: "feature1, feature2, feature3",
+ hasHeader: true,
+ },
+ {
+ name: "empty features slice",
+ features: []string{},
+ expectedHeader: "",
+ hasHeader: false,
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ var capturedHeader string
+ var headerExists bool
+
+ // Create a test server that captures the request header
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ capturedHeader = r.Header.Get(headers.GraphQLFeaturesHeader)
+ headerExists = r.Header.Get(headers.GraphQLFeaturesHeader) != ""
+ w.WriteHeader(http.StatusOK)
+ }))
+ defer server.Close()
+
+ // Create the transport
+ transport := &GraphQLFeaturesTransport{
+ Transport: http.DefaultTransport,
+ }
+
+ // Create a request
+ ctx := context.Background()
+ if tc.features != nil {
+ ctx = ghcontext.WithGraphQLFeatures(ctx, tc.features...)
+ }
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, server.URL, nil)
+ require.NoError(t, err)
+
+ // Execute the request
+ resp, err := transport.RoundTrip(req)
+ require.NoError(t, err)
+ defer resp.Body.Close()
+
+ // Verify the header
+ assert.Equal(t, tc.hasHeader, headerExists)
+ if tc.hasHeader {
+ assert.Equal(t, tc.expectedHeader, capturedHeader)
+ }
+ })
+ }
+}
+
+func TestGraphQLFeaturesTransport_NilTransport(t *testing.T) {
+ t.Parallel()
+
+ var capturedHeader string
+
+ // Create a test server
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ capturedHeader = r.Header.Get(headers.GraphQLFeaturesHeader)
+ w.WriteHeader(http.StatusOK)
+ }))
+ defer server.Close()
+
+ // Create the transport with nil Transport (should use DefaultTransport)
+ transport := &GraphQLFeaturesTransport{
+ Transport: nil,
+ }
+
+ // Create a request with features
+ ctx := ghcontext.WithGraphQLFeatures(context.Background(), "test_feature")
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, server.URL, nil)
+ require.NoError(t, err)
+
+ // Execute the request
+ resp, err := transport.RoundTrip(req)
+ require.NoError(t, err)
+ defer resp.Body.Close()
+
+ // Verify the header was added
+ assert.Equal(t, "test_feature", capturedHeader)
+}
+
+func TestGraphQLFeaturesTransport_DoesNotMutateOriginalRequest(t *testing.T) {
+ t.Parallel()
+
+ // Create a test server
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ }))
+ defer server.Close()
+
+ // Create the transport
+ transport := &GraphQLFeaturesTransport{
+ Transport: http.DefaultTransport,
+ }
+
+ // Create a request with features
+ ctx := ghcontext.WithGraphQLFeatures(context.Background(), "test_feature")
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, server.URL, nil)
+ require.NoError(t, err)
+
+ // Store the original header value
+ originalHeader := req.Header.Get(headers.GraphQLFeaturesHeader)
+
+ // Execute the request
+ resp, err := transport.RoundTrip(req)
+ require.NoError(t, err)
+ defer resp.Body.Close()
+
+ // Verify the original request was not mutated
+ assert.Equal(t, originalHeader, req.Header.Get(headers.GraphQLFeaturesHeader))
+}
diff --git a/pkg/http/transport/user_agent.go b/pkg/http/transport/user_agent.go
new file mode 100644
index 000000000..a489941cc
--- /dev/null
+++ b/pkg/http/transport/user_agent.go
@@ -0,0 +1,18 @@
+package transport
+
+import (
+ "net/http"
+
+ "github.com/github/github-mcp-server/pkg/http/headers"
+)
+
+type UserAgentTransport struct {
+ Transport http.RoundTripper
+ Agent string
+}
+
+func (t *UserAgentTransport) RoundTrip(req *http.Request) (*http.Response, error) {
+ req = req.Clone(req.Context())
+ req.Header.Set(headers.UserAgentHeader, t.Agent)
+ return t.Transport.RoundTrip(req)
+}
diff --git a/pkg/inventory/builder.go b/pkg/inventory/builder.go
index a0ed2baee..ff2d06d5d 100644
--- a/pkg/inventory/builder.go
+++ b/pkg/inventory/builder.go
@@ -2,6 +2,7 @@ package inventory
import (
"context"
+ "fmt"
"sort"
"strings"
)
@@ -33,12 +34,13 @@ type Builder struct {
deprecatedAliases map[string]string
// Configuration options (processed at Build time)
- readOnly bool
- toolsetIDs []string // raw input, processed at Build()
- toolsetIDsIsNil bool // tracks if nil was passed (nil = defaults)
- additionalTools []string // raw input, processed at Build()
- featureChecker FeatureFlagChecker
- filters []ToolFilter // filters to apply to all tools
+ readOnly bool
+ toolsetIDs []string // raw input, processed at Build()
+ toolsetIDsIsNil bool // tracks if nil was passed (nil = defaults)
+ additionalTools []string // raw input, processed at Build()
+ featureChecker FeatureFlagChecker
+ filters []ToolFilter // filters to apply to all tools
+ generateInstructions bool
}
// NewBuilder creates a new Builder.
@@ -83,6 +85,11 @@ func (b *Builder) WithReadOnly(readOnly bool) *Builder {
return b
}
+func (b *Builder) WithServerInstructions() *Builder {
+ b.generateInstructions = true
+ return b
+}
+
// WithToolsets specifies which toolsets should be enabled.
// Special keywords:
// - "all": enables all toolsets
@@ -101,6 +108,7 @@ func (b *Builder) WithToolsets(toolsetIDs []string) *Builder {
// WithTools specifies additional tools that bypass toolset filtering.
// These tools are additive - they will be included even if their toolset is not enabled.
// Read-only filtering still applies to these tools.
+// Input is cleaned (trimmed, deduplicated) during Build().
// Deprecated tool aliases are automatically resolved to their canonical names during Build().
// Returns self for chaining.
func (b *Builder) WithTools(toolNames []string) *Builder {
@@ -127,11 +135,33 @@ func (b *Builder) WithFilter(filter ToolFilter) *Builder {
return b
}
+// cleanTools trims whitespace and removes duplicates from tool names.
+// Empty strings after trimming are excluded.
+func cleanTools(tools []string) []string {
+ seen := make(map[string]bool)
+ var cleaned []string
+ for _, name := range tools {
+ trimmed := strings.TrimSpace(name)
+ if trimmed == "" {
+ continue
+ }
+ if !seen[trimmed] {
+ seen[trimmed] = true
+ cleaned = append(cleaned, trimmed)
+ }
+ }
+ return cleaned
+}
+
// Build creates the final Inventory with all configuration applied.
// This processes toolset filtering, tool name resolution, and sets up
// the inventory for use. The returned Inventory is ready for use with
// AvailableTools(), RegisterAll(), etc.
-func (b *Builder) Build() *Inventory {
+//
+// Build returns an error if any tools specified via WithTools() are not recognized
+// (i.e., they don't exist in the tool set and are not deprecated aliases).
+// This ensures invalid tool configurations fail fast at build time.
+func (b *Builder) Build() (*Inventory, error) {
r := &Inventory{
tools: b.tools,
resourceTemplates: b.resourceTemplates,
@@ -145,20 +175,44 @@ func (b *Builder) Build() *Inventory {
// Process toolsets and pre-compute metadata in a single pass
r.enabledToolsets, r.unrecognizedToolsets, r.toolsetIDs, r.toolsetIDSet, r.defaultToolsetIDs, r.toolsetDescriptions = b.processToolsets()
- // Process additional tools (resolve aliases)
+ // Build set of valid tool names for validation
+ validToolNames := make(map[string]bool, len(b.tools))
+ for i := range b.tools {
+ validToolNames[b.tools[i].Tool.Name] = true
+ }
+
+ // Process additional tools (clean, resolve aliases, and track unrecognized)
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
+ cleanedTools := cleanTools(b.additionalTools)
+
+ r.additionalTools = make(map[string]bool, len(cleanedTools))
+ var unrecognizedTools []string
+ for _, name := range cleanedTools {
+ // 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
+ } else if !validToolNames[name] {
+ // Not a valid tool and not a deprecated alias - track as unrecognized
+ unrecognizedTools = append(unrecognizedTools, name)
}
}
+
+ // Error out if there are unrecognized tools
+ if len(unrecognizedTools) > 0 {
+ return nil, fmt.Errorf("unrecognized tools: %s", strings.Join(unrecognizedTools, ", "))
+ }
+ }
+
+ if b.generateInstructions {
+ r.instructions = generateInstructions(r)
}
- return r
+ return r, nil
}
// processToolsets processes the toolsetIDs configuration and returns:
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/instructions.go b/pkg/inventory/instructions.go
new file mode 100644
index 000000000..02e90cd20
--- /dev/null
+++ b/pkg/inventory/instructions.go
@@ -0,0 +1,43 @@
+package inventory
+
+import (
+ "os"
+ "strings"
+)
+
+// generateInstructions creates server instructions based on enabled toolsets
+func generateInstructions(inv *Inventory) string {
+ // For testing - add a flag to disable instructions
+ if os.Getenv("DISABLE_INSTRUCTIONS") == "true" {
+ return "" // Baseline mode
+ }
+
+ var instructions []string
+
+ // Base instruction with context management
+ baseInstruction := `The GitHub MCP Server provides tools to interact with GitHub platform.
+
+Tool selection guidance:
+ 1. Use 'list_*' tools for broad, simple retrieval and pagination of all items of a type (e.g., all issues, all PRs, all branches) with basic filtering.
+ 2. Use 'search_*' tools for targeted queries with specific criteria, keywords, or complex filters (e.g., issues with certain text, PRs by author, code containing functions).
+
+Context management:
+ 1. Use pagination whenever possible with batches of 5-10 items.
+ 2. Use minimal_output parameter set to true if the full information is not needed to accomplish a task.
+
+Tool usage guidance:
+ 1. For 'search_*' tools: Use separate 'sort' and 'order' parameters if available for sorting results - do not include 'sort:' syntax in query strings. Query strings should contain only search criteria (e.g., 'org:google language:python'), not sorting instructions.`
+
+ instructions = append(instructions, baseInstruction)
+
+ // Collect instructions from each enabled toolset
+ for _, toolset := range inv.EnabledToolsets() {
+ if toolset.InstructionsFunc != nil {
+ if toolsetInstructions := toolset.InstructionsFunc(inv); toolsetInstructions != "" {
+ instructions = append(instructions, toolsetInstructions)
+ }
+ }
+ }
+
+ return strings.Join(instructions, " ")
+}
diff --git a/pkg/inventory/instructions_test.go b/pkg/inventory/instructions_test.go
new file mode 100644
index 000000000..e8e369b3d
--- /dev/null
+++ b/pkg/inventory/instructions_test.go
@@ -0,0 +1,265 @@
+package inventory
+
+import (
+ "os"
+ "strings"
+ "testing"
+)
+
+// createTestInventory creates an inventory with the specified toolsets for testing.
+// All toolsets are enabled by default using WithToolsets([]string{"all"}).
+func createTestInventory(toolsets []ToolsetMetadata) *Inventory {
+ // Create tools for each toolset so they show up in AvailableToolsets()
+ var tools []ServerTool
+ for _, ts := range toolsets {
+ tools = append(tools, ServerTool{
+ Toolset: ts,
+ })
+ }
+
+ inv, _ := NewBuilder().
+ SetTools(tools).
+ WithToolsets([]string{"all"}).
+ Build()
+
+ return inv
+}
+
+func TestGenerateInstructions(t *testing.T) {
+ tests := []struct {
+ name string
+ toolsets []ToolsetMetadata
+ expectedEmpty bool
+ }{
+ {
+ name: "empty toolsets",
+ toolsets: []ToolsetMetadata{},
+ expectedEmpty: false, // base instructions are always included
+ },
+ {
+ name: "toolset with instructions",
+ toolsets: []ToolsetMetadata{
+ {
+ ID: "test",
+ Description: "Test toolset",
+ InstructionsFunc: func(_ *Inventory) string {
+ return "Test instructions"
+ },
+ },
+ },
+ expectedEmpty: false,
+ },
+ {
+ name: "toolset without instructions",
+ toolsets: []ToolsetMetadata{
+ {
+ ID: "test",
+ Description: "Test toolset",
+ },
+ },
+ expectedEmpty: false, // base instructions still included
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ inv := createTestInventory(tt.toolsets)
+ result := generateInstructions(inv)
+
+ if tt.expectedEmpty {
+ if result != "" {
+ t.Errorf("Expected empty instructions but got: %s", result)
+ }
+ } else {
+ if result == "" {
+ t.Errorf("Expected non-empty instructions but got empty result")
+ }
+ }
+ })
+ }
+}
+
+func TestGenerateInstructionsWithDisableFlag(t *testing.T) {
+ tests := []struct {
+ name string
+ disableEnvValue string
+ expectedEmpty bool
+ }{
+ {
+ name: "DISABLE_INSTRUCTIONS=true returns empty",
+ disableEnvValue: "true",
+ expectedEmpty: true,
+ },
+ {
+ name: "DISABLE_INSTRUCTIONS=false returns normal instructions",
+ disableEnvValue: "false",
+ expectedEmpty: false,
+ },
+ {
+ name: "DISABLE_INSTRUCTIONS unset returns normal instructions",
+ disableEnvValue: "",
+ expectedEmpty: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ // Save original env value
+ originalValue := os.Getenv("DISABLE_INSTRUCTIONS")
+ defer func() {
+ if originalValue == "" {
+ os.Unsetenv("DISABLE_INSTRUCTIONS")
+ } else {
+ os.Setenv("DISABLE_INSTRUCTIONS", originalValue)
+ }
+ }()
+
+ // Set test env value
+ if tt.disableEnvValue == "" {
+ os.Unsetenv("DISABLE_INSTRUCTIONS")
+ } else {
+ os.Setenv("DISABLE_INSTRUCTIONS", tt.disableEnvValue)
+ }
+
+ inv := createTestInventory([]ToolsetMetadata{
+ {ID: "test", Description: "Test"},
+ })
+ result := generateInstructions(inv)
+
+ if tt.expectedEmpty {
+ if result != "" {
+ t.Errorf("Expected empty instructions but got: %s", result)
+ }
+ } else {
+ if result == "" {
+ t.Errorf("Expected non-empty instructions but got empty result")
+ }
+ }
+ })
+ }
+}
+
+func TestToolsetInstructionsFunc(t *testing.T) {
+ tests := []struct {
+ name string
+ toolsets []ToolsetMetadata
+ expectedToContain string
+ notExpectedToContain string
+ }{
+ {
+ name: "toolset with context-aware instructions includes extra text when dependency present",
+ toolsets: []ToolsetMetadata{
+ {ID: "repos", Description: "Repos"},
+ {
+ ID: "pull_requests",
+ Description: "PRs",
+ InstructionsFunc: func(inv *Inventory) string {
+ instructions := "PR base instructions"
+ if inv.HasToolset("repos") {
+ instructions += " PR template instructions"
+ }
+ return instructions
+ },
+ },
+ },
+ expectedToContain: "PR template instructions",
+ },
+ {
+ name: "toolset with context-aware instructions excludes extra text when dependency missing",
+ toolsets: []ToolsetMetadata{
+ {
+ ID: "pull_requests",
+ Description: "PRs",
+ InstructionsFunc: func(inv *Inventory) string {
+ instructions := "PR base instructions"
+ if inv.HasToolset("repos") {
+ instructions += " PR template instructions"
+ }
+ return instructions
+ },
+ },
+ },
+ notExpectedToContain: "PR template instructions",
+ },
+ {
+ name: "toolset without InstructionsFunc returns no toolset-specific instructions",
+ toolsets: []ToolsetMetadata{
+ {ID: "test", Description: "Test without instructions"},
+ },
+ notExpectedToContain: "## Test",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ inv := createTestInventory(tt.toolsets)
+ result := generateInstructions(inv)
+
+ if tt.expectedToContain != "" && !strings.Contains(result, tt.expectedToContain) {
+ t.Errorf("Expected result to contain '%s', but it did not. Result: %s", tt.expectedToContain, result)
+ }
+
+ if tt.notExpectedToContain != "" && strings.Contains(result, tt.notExpectedToContain) {
+ t.Errorf("Did not expect result to contain '%s', but it did. Result: %s", tt.notExpectedToContain, result)
+ }
+ })
+ }
+}
+
+// TestGenerateInstructionsOnlyEnabledToolsets verifies that generateInstructions
+// only includes instructions from enabled toolsets, not all available toolsets.
+// This is a regression test for https://github.com/github/github-mcp-server/issues/1897
+func TestGenerateInstructionsOnlyEnabledToolsets(t *testing.T) {
+ // Create tools for multiple toolsets
+ reposToolset := ToolsetMetadata{
+ ID: "repos",
+ Description: "Repository tools",
+ InstructionsFunc: func(_ *Inventory) string {
+ return "REPOS_INSTRUCTIONS"
+ },
+ }
+ issuesToolset := ToolsetMetadata{
+ ID: "issues",
+ Description: "Issue tools",
+ InstructionsFunc: func(_ *Inventory) string {
+ return "ISSUES_INSTRUCTIONS"
+ },
+ }
+ prsToolset := ToolsetMetadata{
+ ID: "pull_requests",
+ Description: "PR tools",
+ InstructionsFunc: func(_ *Inventory) string {
+ return "PRS_INSTRUCTIONS"
+ },
+ }
+
+ tools := []ServerTool{
+ {Toolset: reposToolset},
+ {Toolset: issuesToolset},
+ {Toolset: prsToolset},
+ }
+
+ // Build inventory with only "repos" toolset enabled
+ inv, err := NewBuilder().
+ SetTools(tools).
+ WithToolsets([]string{"repos"}).
+ Build()
+ if err != nil {
+ t.Fatalf("Failed to build inventory: %v", err)
+ }
+
+ result := generateInstructions(inv)
+
+ // Should contain instructions from enabled toolset
+ if !strings.Contains(result, "REPOS_INSTRUCTIONS") {
+ t.Errorf("Expected instructions to contain 'REPOS_INSTRUCTIONS' for enabled toolset, but it did not. Result: %s", result)
+ }
+
+ // Should NOT contain instructions from non-enabled toolsets
+ if strings.Contains(result, "ISSUES_INSTRUCTIONS") {
+ t.Errorf("Did not expect instructions to contain 'ISSUES_INSTRUCTIONS' for disabled toolset, but it did. Result: %s", result)
+ }
+ if strings.Contains(result, "PRS_INSTRUCTIONS") {
+ t.Errorf("Did not expect instructions to contain 'PRS_INSTRUCTIONS' for disabled toolset, but it did. Result: %s", result)
+ }
+}
diff --git a/pkg/inventory/registry.go b/pkg/inventory/registry.go
index f3691e38a..e2cd3a9e6 100644
--- a/pkg/inventory/registry.go
+++ b/pkg/inventory/registry.go
@@ -58,6 +58,8 @@ type Inventory struct {
filters []ToolFilter
// unrecognizedToolsets holds toolset IDs that were requested but don't match any registered toolsets
unrecognizedToolsets []string
+ // server instructions hold high-level instructions for agents to use the server effectively
+ instructions string
}
// UnrecognizedToolsets returns toolset IDs that were passed to WithToolsets but don't
@@ -91,7 +93,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 +136,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:
@@ -294,3 +294,29 @@ func (r *Inventory) AvailableToolsets(exclude ...ToolsetID) []ToolsetMetadata {
}
return result
}
+
+// EnabledToolsets returns the unique toolsets that are enabled based on current filters.
+// This is similar to AvailableToolsets but respects the enabledToolsets filter.
+// Returns toolsets in sorted order by toolset ID.
+func (r *Inventory) EnabledToolsets() []ToolsetMetadata {
+ // Get all available toolsets first (already sorted by ID)
+ allToolsets := r.AvailableToolsets()
+
+ // If no filter is set, all toolsets are enabled
+ if r.enabledToolsets == nil {
+ return allToolsets
+ }
+
+ // Filter to only enabled toolsets
+ var result []ToolsetMetadata
+ for _, ts := range allToolsets {
+ if r.enabledToolsets[ts.ID] {
+ result = append(result, ts)
+ }
+ }
+ return result
+}
+
+func (r *Inventory) Instructions() string {
+ return r.instructions
+}
diff --git a/pkg/inventory/registry_test.go b/pkg/inventory/registry_test.go
index 41e94b8d9..bb3337af0 100644
--- a/pkg/inventory/registry_test.go
+++ b/pkg/inventory/registry_test.go
@@ -7,8 +7,18 @@ import (
"testing"
"github.com/modelcontextprotocol/go-sdk/mcp"
+ "github.com/stretchr/testify/require"
)
+// mustBuild is a test helper that calls Build() and fails the test if an error occurs.
+// Use this for tests where Build() is not expected to fail.
+func mustBuild(t *testing.T, b *Builder) *Inventory {
+ t.Helper()
+ inv, err := b.Build()
+ require.NoError(t, err)
+ return inv
+}
+
// testToolsetMetadata returns a ToolsetMetadata for testing
func testToolsetMetadata(id string) ToolsetMetadata {
return ToolsetMetadata{
@@ -65,7 +75,7 @@ func mockTool(name string, toolsetID string, readOnly bool) ServerTool {
}
func TestNewRegistryEmpty(t *testing.T) {
- reg := NewBuilder().Build()
+ reg := mustBuild(t, NewBuilder())
if len(reg.AvailableTools(context.Background())) != 0 {
t.Fatalf("Expected tools to be empty")
}
@@ -84,7 +94,7 @@ func TestNewRegistryWithTools(t *testing.T) {
mockTool("tool3", "toolset2", true),
}
- reg := NewBuilder().SetTools(tools).Build()
+ reg := mustBuild(t, NewBuilder().SetTools(tools))
if len(reg.AllTools()) != 3 {
t.Errorf("Expected 3 tools, got %d", len(reg.AllTools()))
@@ -98,7 +108,7 @@ func TestAvailableTools_NoFilters(t *testing.T) {
mockTool("tool_c", "toolset2", true),
}
- reg := NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).Build()
+ reg := mustBuild(t, NewBuilder().SetTools(tools).WithToolsets([]string{"all"}))
available := reg.AvailableTools(context.Background())
if len(available) != 3 {
@@ -121,14 +131,14 @@ func TestWithReadOnly(t *testing.T) {
}
// Build without read-only - should have both tools
- reg := NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).Build()
+ reg := mustBuild(t, NewBuilder().SetTools(tools).WithToolsets([]string{"all"}))
allTools := reg.AvailableTools(context.Background())
if len(allTools) != 2 {
t.Fatalf("Expected 2 tools without read-only, got %d", len(allTools))
}
// Build with read-only - should filter out write tools
- readOnlyReg := NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).WithReadOnly(true).Build()
+ readOnlyReg := mustBuild(t, NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).WithReadOnly(true))
readOnlyTools := readOnlyReg.AvailableTools(context.Background())
if len(readOnlyTools) != 1 {
t.Fatalf("Expected 1 tool in read-only, got %d", len(readOnlyTools))
@@ -146,14 +156,14 @@ func TestWithToolsets(t *testing.T) {
}
// Build with all toolsets
- allReg := NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).Build()
+ allReg := mustBuild(t, NewBuilder().SetTools(tools).WithToolsets([]string{"all"}))
allTools := allReg.AvailableTools(context.Background())
if len(allTools) != 3 {
t.Fatalf("Expected 3 tools without filter, got %d", len(allTools))
}
// Build with specific toolsets
- filteredReg := NewBuilder().SetTools(tools).WithToolsets([]string{"toolset1", "toolset3"}).Build()
+ filteredReg := mustBuild(t, NewBuilder().SetTools(tools).WithToolsets([]string{"toolset1", "toolset3"}))
filteredTools := filteredReg.AvailableTools(context.Background())
if len(filteredTools) != 2 {
@@ -177,7 +187,7 @@ func TestWithToolsetsTrimsWhitespace(t *testing.T) {
}
// Whitespace should be trimmed
- filteredReg := NewBuilder().SetTools(tools).WithToolsets([]string{" toolset1 ", " toolset2 "}).Build()
+ filteredReg := mustBuild(t, NewBuilder().SetTools(tools).WithToolsets([]string{" toolset1 ", " toolset2 "}))
filteredTools := filteredReg.AvailableTools(context.Background())
if len(filteredTools) != 2 {
@@ -191,7 +201,7 @@ func TestWithToolsetsDeduplicates(t *testing.T) {
}
// Duplicates should be removed
- filteredReg := NewBuilder().SetTools(tools).WithToolsets([]string{"toolset1", "toolset1", " toolset1 "}).Build()
+ filteredReg := mustBuild(t, NewBuilder().SetTools(tools).WithToolsets([]string{"toolset1", "toolset1", " toolset1 "}))
filteredTools := filteredReg.AvailableTools(context.Background())
if len(filteredTools) != 1 {
@@ -205,7 +215,7 @@ func TestWithToolsetsIgnoresEmptyStrings(t *testing.T) {
}
// Empty strings should be ignored
- filteredReg := NewBuilder().SetTools(tools).WithToolsets([]string{"", "toolset1", " ", ""}).Build()
+ filteredReg := mustBuild(t, NewBuilder().SetTools(tools).WithToolsets([]string{"", "toolset1", " ", ""}))
filteredTools := filteredReg.AvailableTools(context.Background())
if len(filteredTools) != 1 {
@@ -253,7 +263,7 @@ func TestUnrecognizedToolsets(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- filtered := NewBuilder().SetTools(tools).WithToolsets(tt.input).Build()
+ filtered := mustBuild(t, NewBuilder().SetTools(tools).WithToolsets(tt.input))
unrecognized := filtered.UnrecognizedToolsets()
if len(unrecognized) != len(tt.expectedUnrecognized) {
@@ -270,6 +280,109 @@ func TestUnrecognizedToolsets(t *testing.T) {
}
}
+func TestBuildErrorsOnUnrecognizedTools(t *testing.T) {
+ tools := []ServerTool{
+ mockTool("tool1", "toolset1", true),
+ mockTool("tool2", "toolset2", true),
+ }
+
+ deprecatedAliases := map[string]string{
+ "old_tool": "tool1",
+ }
+
+ tests := []struct {
+ name string
+ withTools []string
+ expectError bool
+ errorContains string
+ }{
+ {
+ name: "all valid",
+ withTools: []string{"tool1", "tool2"},
+ expectError: false,
+ },
+ {
+ name: "one invalid",
+ withTools: []string{"tool1", "blabla"},
+ expectError: true,
+ errorContains: "blabla",
+ },
+ {
+ name: "multiple invalid",
+ withTools: []string{"invalid1", "tool1", "invalid2"},
+ expectError: true,
+ errorContains: "invalid1",
+ },
+ {
+ name: "deprecated alias is valid",
+ withTools: []string{"old_tool"},
+ expectError: false,
+ },
+ {
+ name: "mixed valid and deprecated alias",
+ withTools: []string{"old_tool", "tool2"},
+ expectError: false,
+ },
+ {
+ name: "empty input",
+ withTools: []string{},
+ expectError: false,
+ },
+ {
+ name: "whitespace trimmed from valid tool",
+ withTools: []string{" tool1 ", " tool2 "},
+ expectError: false,
+ },
+ {
+ name: "whitespace trimmed from invalid tool",
+ withTools: []string{" invalid_tool "},
+ expectError: true,
+ errorContains: "invalid_tool",
+ },
+ {
+ name: "duplicate tools deduplicated",
+ withTools: []string{"tool1", "tool1"},
+ expectError: false,
+ },
+ {
+ name: "duplicate invalid tools deduplicated",
+ withTools: []string{"blabla", "blabla"},
+ expectError: true,
+ errorContains: "blabla",
+ },
+ {
+ name: "mixed whitespace and duplicates",
+ withTools: []string{" tool1 ", "tool1", " tool1 "},
+ expectError: false,
+ },
+ {
+ name: "empty strings ignored",
+ withTools: []string{"", "tool1", " ", ""},
+ expectError: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ inv, err := NewBuilder().
+ SetTools(tools).
+ WithDeprecatedAliases(deprecatedAliases).
+ WithToolsets([]string{"all"}).
+ WithTools(tt.withTools).
+ Build()
+
+ if tt.expectError {
+ require.Error(t, err, "Expected error for unrecognized tools")
+ require.Contains(t, err.Error(), tt.errorContains)
+ require.Nil(t, inv)
+ } else {
+ require.NoError(t, err)
+ require.NotNil(t, inv)
+ }
+ })
+ }
+}
+
func TestWithTools(t *testing.T) {
tools := []ServerTool{
mockTool("tool1", "toolset1", true),
@@ -279,7 +392,7 @@ func TestWithTools(t *testing.T) {
// WithTools adds additional tools that bypass toolset filtering
// When combined with WithToolsets([]), only the additional tools should be available
- filteredReg := NewBuilder().SetTools(tools).WithToolsets([]string{}).WithTools([]string{"tool1", "tool3"}).Build()
+ filteredReg := mustBuild(t, NewBuilder().SetTools(tools).WithToolsets([]string{}).WithTools([]string{"tool1", "tool3"}))
filteredTools := filteredReg.AvailableTools(context.Background())
if len(filteredTools) != 2 {
@@ -304,7 +417,7 @@ func TestChainedFilters(t *testing.T) {
}
// Chain read-only and toolset filter
- filtered := NewBuilder().SetTools(tools).WithReadOnly(true).WithToolsets([]string{"toolset1"}).Build()
+ filtered := mustBuild(t, NewBuilder().SetTools(tools).WithReadOnly(true).WithToolsets([]string{"toolset1"}))
result := filtered.AvailableTools(context.Background())
if len(result) != 1 {
@@ -322,7 +435,7 @@ func TestToolsetIDs(t *testing.T) {
mockTool("tool3", "toolset_b", true), // duplicate toolset
}
- reg := NewBuilder().SetTools(tools).Build()
+ reg := mustBuild(t, NewBuilder().SetTools(tools))
ids := reg.ToolsetIDs()
if len(ids) != 2 {
@@ -341,7 +454,7 @@ func TestToolsetDescriptions(t *testing.T) {
mockTool("tool2", "toolset2", true),
}
- reg := NewBuilder().SetTools(tools).Build()
+ reg := mustBuild(t, NewBuilder().SetTools(tools))
descriptions := reg.ToolsetDescriptions()
if len(descriptions) != 2 {
@@ -360,7 +473,7 @@ func TestToolsForToolset(t *testing.T) {
mockTool("tool3", "toolset2", true),
}
- reg := NewBuilder().SetTools(tools).Build()
+ reg := mustBuild(t, NewBuilder().SetTools(tools))
toolset1Tools := reg.ToolsForToolset("toolset1")
if len(toolset1Tools) != 2 {
@@ -373,10 +486,10 @@ func TestWithDeprecatedAliases(t *testing.T) {
mockTool("new_name", "toolset1", true),
}
- reg := NewBuilder().SetTools(tools).WithDeprecatedAliases(map[string]string{
+ reg := mustBuild(t, NewBuilder().SetTools(tools).WithDeprecatedAliases(map[string]string{
"old_name": "new_name",
"get_issue": "issue_read",
- }).Build()
+ }))
// Test resolving aliases
resolved, aliasesUsed := reg.ResolveToolAliases([]string{"old_name"})
@@ -394,10 +507,10 @@ func TestResolveToolAliases(t *testing.T) {
mockTool("some_tool", "toolset1", true),
}
- reg := NewBuilder().SetTools(tools).
+ reg := mustBuild(t, NewBuilder().SetTools(tools).
WithDeprecatedAliases(map[string]string{
"get_issue": "issue_read",
- }).Build()
+ }))
// Test resolving a mix of aliases and canonical names
input := []string{"get_issue", "some_tool"}
@@ -426,7 +539,7 @@ func TestFindToolByName(t *testing.T) {
mockTool("issue_read", "toolset1", true),
}
- reg := NewBuilder().SetTools(tools).Build()
+ reg := mustBuild(t, NewBuilder().SetTools(tools))
// Find by name
tool, toolsetID, err := reg.FindToolByName("issue_read")
@@ -456,7 +569,7 @@ func TestWithToolsAdditive(t *testing.T) {
// Test WithTools bypasses toolset filtering
// Enable only toolset2, but add issue_read as additional tool
- filtered := NewBuilder().SetTools(tools).WithToolsets([]string{"toolset2"}).WithTools([]string{"issue_read"}).Build()
+ filtered := mustBuild(t, NewBuilder().SetTools(tools).WithToolsets([]string{"toolset2"}).WithTools([]string{"issue_read"}))
available := filtered.AvailableTools(context.Background())
if len(available) != 2 {
@@ -476,7 +589,7 @@ func TestWithToolsAdditive(t *testing.T) {
}
// Test WithTools respects read-only mode
- readOnlyFiltered := NewBuilder().SetTools(tools).WithReadOnly(true).WithTools([]string{"issue_write"}).Build()
+ readOnlyFiltered := mustBuild(t, NewBuilder().SetTools(tools).WithReadOnly(true).WithTools([]string{"issue_write"}))
available = readOnlyFiltered.AvailableTools(context.Background())
// issue_write should be excluded because read-only applies to additional tools too
@@ -486,12 +599,10 @@ func TestWithToolsAdditive(t *testing.T) {
}
}
- // Test WithTools with non-existent tool (should not error, just won't match anything)
- nonexistent := NewBuilder().SetTools(tools).WithToolsets([]string{}).WithTools([]string{"nonexistent"}).Build()
- available = nonexistent.AvailableTools(context.Background())
- if len(available) != 0 {
- t.Errorf("expected 0 tools for non-existent additional tool, got %d", len(available))
- }
+ // Test WithTools with non-existent tool (should error during Build)
+ _, err := NewBuilder().SetTools(tools).WithToolsets([]string{}).WithTools([]string{"nonexistent"}).Build()
+ require.Error(t, err, "expected error for non-existent tool")
+ require.Contains(t, err.Error(), "nonexistent")
}
func TestWithToolsResolvesAliases(t *testing.T) {
@@ -500,13 +611,12 @@ func TestWithToolsResolvesAliases(t *testing.T) {
}
// Using deprecated alias should resolve to canonical name
- filtered := NewBuilder().SetTools(tools).
+ filtered := mustBuild(t, NewBuilder().SetTools(tools).
WithDeprecatedAliases(map[string]string{
"get_issue": "issue_read",
}).
WithToolsets([]string{}).
- WithTools([]string{"get_issue"}).
- Build()
+ WithTools([]string{"get_issue"}))
available := filtered.AvailableTools(context.Background())
if len(available) != 1 {
@@ -522,7 +632,7 @@ func TestHasToolset(t *testing.T) {
mockTool("tool1", "toolset1", true),
}
- reg := NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).Build()
+ reg := mustBuild(t, NewBuilder().SetTools(tools).WithToolsets([]string{"all"}))
if !reg.HasToolset("toolset1") {
t.Error("expected HasToolset to return true for existing toolset")
@@ -539,14 +649,14 @@ func TestEnabledToolsetIDs(t *testing.T) {
}
// Without filter, all toolsets are enabled
- reg := NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).Build()
+ reg := mustBuild(t, NewBuilder().SetTools(tools).WithToolsets([]string{"all"}))
ids := reg.EnabledToolsetIDs()
if len(ids) != 2 {
t.Fatalf("Expected 2 enabled toolset IDs, got %d", len(ids))
}
// With filter
- filtered := NewBuilder().SetTools(tools).WithToolsets([]string{"toolset1"}).Build()
+ filtered := mustBuild(t, NewBuilder().SetTools(tools).WithToolsets([]string{"toolset1"}))
filteredIDs := filtered.EnabledToolsetIDs()
if len(filteredIDs) != 1 {
t.Fatalf("Expected 1 enabled toolset ID, got %d", len(filteredIDs))
@@ -563,7 +673,7 @@ func TestAllTools(t *testing.T) {
}
// Even with read-only filter, AllTools returns everything
- readOnlyReg := NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).WithReadOnly(true).Build()
+ readOnlyReg := mustBuild(t, NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).WithReadOnly(true))
allTools := readOnlyReg.AllTools()
if len(allTools) != 2 {
@@ -628,7 +738,7 @@ func TestForMCPRequest_Initialize(t *testing.T) {
mockPrompt("prompt1", "repos"),
}
- reg := NewBuilder().SetTools(tools).SetResources(resources).SetPrompts(prompts).WithToolsets([]string{"all"}).Build()
+ reg := mustBuild(t, NewBuilder().SetTools(tools).SetResources(resources).SetPrompts(prompts).WithToolsets([]string{"all"}))
filtered := reg.ForMCPRequest(MCPMethodInitialize, "")
// Initialize should return empty - capabilities come from ServerOptions
@@ -655,7 +765,7 @@ func TestForMCPRequest_ToolsList(t *testing.T) {
mockPrompt("prompt1", "repos"),
}
- reg := NewBuilder().SetTools(tools).SetResources(resources).SetPrompts(prompts).WithToolsets([]string{"all"}).Build()
+ reg := mustBuild(t, NewBuilder().SetTools(tools).SetResources(resources).SetPrompts(prompts).WithToolsets([]string{"all"}))
filtered := reg.ForMCPRequest(MCPMethodToolsList, "")
// tools/list should return all tools, no resources or prompts
@@ -677,7 +787,7 @@ func TestForMCPRequest_ToolsCall(t *testing.T) {
mockTool("list_repos", "repos", true),
}
- reg := NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).Build()
+ reg := mustBuild(t, NewBuilder().SetTools(tools).WithToolsets([]string{"all"}))
filtered := reg.ForMCPRequest(MCPMethodToolsCall, "get_me")
available := filtered.AvailableTools(context.Background())
@@ -694,7 +804,7 @@ func TestForMCPRequest_ToolsCall_NotFound(t *testing.T) {
mockTool("get_me", "context", true),
}
- reg := NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).Build()
+ reg := mustBuild(t, NewBuilder().SetTools(tools).WithToolsets([]string{"all"}))
filtered := reg.ForMCPRequest(MCPMethodToolsCall, "nonexistent")
if len(filtered.AvailableTools(context.Background())) != 0 {
@@ -708,11 +818,11 @@ func TestForMCPRequest_ToolsCall_DeprecatedAlias(t *testing.T) {
mockTool("list_commits", "repos", true),
}
- reg := NewBuilder().SetTools(tools).
+ reg := mustBuild(t, NewBuilder().SetTools(tools).
WithToolsets([]string{"all"}).
WithDeprecatedAliases(map[string]string{
"old_get_me": "get_me",
- }).Build()
+ }))
// Request using the deprecated alias
filtered := reg.ForMCPRequest(MCPMethodToolsCall, "old_get_me")
@@ -732,7 +842,7 @@ func TestForMCPRequest_ToolsCall_RespectsFilters(t *testing.T) {
}
// Apply read-only filter at build time, then ForMCPRequest
- reg := NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).WithReadOnly(true).Build()
+ reg := mustBuild(t, NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).WithReadOnly(true))
filtered := reg.ForMCPRequest(MCPMethodToolsCall, "create_issue")
// The tool exists in the filtered group, but AvailableTools respects read-only
@@ -754,7 +864,7 @@ func TestForMCPRequest_ResourcesList(t *testing.T) {
mockPrompt("prompt1", "repos"),
}
- reg := NewBuilder().SetTools(tools).SetResources(resources).SetPrompts(prompts).WithToolsets([]string{"all"}).Build()
+ reg := mustBuild(t, NewBuilder().SetTools(tools).SetResources(resources).SetPrompts(prompts).WithToolsets([]string{"all"}))
filtered := reg.ForMCPRequest(MCPMethodResourcesList, "")
if len(filtered.AvailableTools(context.Background())) != 0 {
@@ -774,18 +884,16 @@ func TestForMCPRequest_ResourcesRead(t *testing.T) {
mockResource("res2", "repos", "branch://{owner}/{repo}/{branch}"),
}
- reg := NewBuilder().SetResources(resources).WithToolsets([]string{"all"}).Build()
- filtered := reg.ForMCPRequest(MCPMethodResourcesRead, "repo://{owner}/{repo}")
+ reg := mustBuild(t, NewBuilder().SetResources(resources).WithToolsets([]string{"all"}))
+ // 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),
@@ -798,7 +906,7 @@ func TestForMCPRequest_PromptsList(t *testing.T) {
mockPrompt("prompt2", "issues"),
}
- reg := NewBuilder().SetTools(tools).SetResources(resources).SetPrompts(prompts).WithToolsets([]string{"all"}).Build()
+ reg := mustBuild(t, NewBuilder().SetTools(tools).SetResources(resources).SetPrompts(prompts).WithToolsets([]string{"all"}))
filtered := reg.ForMCPRequest(MCPMethodPromptsList, "")
if len(filtered.AvailableTools(context.Background())) != 0 {
@@ -818,7 +926,7 @@ func TestForMCPRequest_PromptsGet(t *testing.T) {
mockPrompt("prompt2", "issues"),
}
- reg := NewBuilder().SetPrompts(prompts).WithToolsets([]string{"all"}).Build()
+ reg := mustBuild(t, NewBuilder().SetPrompts(prompts).WithToolsets([]string{"all"}))
filtered := reg.ForMCPRequest(MCPMethodPromptsGet, "prompt1")
available := filtered.AvailablePrompts(context.Background())
@@ -841,7 +949,7 @@ func TestForMCPRequest_UnknownMethod(t *testing.T) {
mockPrompt("prompt1", "repos"),
}
- reg := NewBuilder().SetTools(tools).SetResources(resources).SetPrompts(prompts).WithToolsets([]string{"all"}).Build()
+ reg := mustBuild(t, NewBuilder().SetTools(tools).SetResources(resources).SetPrompts(prompts).WithToolsets([]string{"all"}))
filtered := reg.ForMCPRequest("unknown/method", "")
// Unknown methods should return empty
@@ -868,7 +976,7 @@ func TestForMCPRequest_DoesNotMutateOriginal(t *testing.T) {
mockPrompt("prompt1", "repos"),
}
- original := NewBuilder().SetTools(tools).SetResources(resources).SetPrompts(prompts).WithToolsets([]string{"all"}).Build()
+ original := mustBuild(t, NewBuilder().SetTools(tools).SetResources(resources).SetPrompts(prompts).WithToolsets([]string{"all"}))
filtered := original.ForMCPRequest(MCPMethodToolsCall, "tool1")
// Original should be unchanged
@@ -903,10 +1011,9 @@ func TestForMCPRequest_ChainedWithOtherFilters(t *testing.T) {
}
// Chain: default toolsets -> read-only -> specific method
- reg := NewBuilder().SetTools(tools).
+ reg := mustBuild(t, NewBuilder().SetTools(tools).
WithToolsets([]string{"default"}).
- WithReadOnly(true).
- Build()
+ WithReadOnly(true))
filtered := reg.ForMCPRequest(MCPMethodToolsList, "")
available := filtered.AvailableTools(context.Background())
@@ -944,7 +1051,7 @@ func TestForMCPRequest_ResourcesTemplatesList(t *testing.T) {
mockResource("res1", "repos", "repo://{owner}/{repo}"),
}
- reg := NewBuilder().SetTools(tools).SetResources(resources).WithToolsets([]string{"all"}).Build()
+ reg := mustBuild(t, NewBuilder().SetTools(tools).SetResources(resources).WithToolsets([]string{"all"}))
filtered := reg.ForMCPRequest(MCPMethodResourcesTemplatesList, "")
// Same behavior as resources/list
@@ -994,7 +1101,7 @@ func TestFeatureFlagEnable(t *testing.T) {
}
// Without feature checker, tool with FeatureFlagEnable should be excluded
- reg := NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).Build()
+ reg := mustBuild(t, NewBuilder().SetTools(tools).WithToolsets([]string{"all"}))
available := reg.AvailableTools(context.Background())
if len(available) != 1 {
t.Fatalf("Expected 1 tool without feature checker, got %d", len(available))
@@ -1005,7 +1112,7 @@ func TestFeatureFlagEnable(t *testing.T) {
// With feature checker returning false, tool should still be excluded
checkerFalse := func(_ context.Context, _ string) (bool, error) { return false, nil }
- regFalse := NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).WithFeatureChecker(checkerFalse).Build()
+ regFalse := mustBuild(t, NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).WithFeatureChecker(checkerFalse))
availableFalse := regFalse.AvailableTools(context.Background())
if len(availableFalse) != 1 {
t.Fatalf("Expected 1 tool with false checker, got %d", len(availableFalse))
@@ -1015,7 +1122,7 @@ func TestFeatureFlagEnable(t *testing.T) {
checkerTrue := func(_ context.Context, flag string) (bool, error) {
return flag == "my_feature", nil
}
- regTrue := NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).WithFeatureChecker(checkerTrue).Build()
+ regTrue := mustBuild(t, NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).WithFeatureChecker(checkerTrue))
availableTrue := regTrue.AvailableTools(context.Background())
if len(availableTrue) != 2 {
t.Fatalf("Expected 2 tools with true checker, got %d", len(availableTrue))
@@ -1029,7 +1136,7 @@ func TestFeatureFlagDisable(t *testing.T) {
}
// Without feature checker, tool with FeatureFlagDisable should be included (flag is false)
- reg := NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).Build()
+ reg := mustBuild(t, NewBuilder().SetTools(tools).WithToolsets([]string{"all"}))
available := reg.AvailableTools(context.Background())
if len(available) != 2 {
t.Fatalf("Expected 2 tools without feature checker, got %d", len(available))
@@ -1039,7 +1146,7 @@ func TestFeatureFlagDisable(t *testing.T) {
checkerTrue := func(_ context.Context, flag string) (bool, error) {
return flag == "kill_switch", nil
}
- regFiltered := NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).WithFeatureChecker(checkerTrue).Build()
+ regFiltered := mustBuild(t, NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).WithFeatureChecker(checkerTrue))
availableFiltered := regFiltered.AvailableTools(context.Background())
if len(availableFiltered) != 1 {
t.Fatalf("Expected 1 tool with kill_switch enabled, got %d", len(availableFiltered))
@@ -1057,21 +1164,21 @@ func TestFeatureFlagBoth(t *testing.T) {
// Enable flag not set -> excluded
checker1 := func(_ context.Context, _ string) (bool, error) { return false, nil }
- reg1 := NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).WithFeatureChecker(checker1).Build()
+ reg1 := mustBuild(t, NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).WithFeatureChecker(checker1))
if len(reg1.AvailableTools(context.Background())) != 0 {
t.Error("Tool should be excluded when enable flag is false")
}
// Enable flag set, disable flag not set -> included
checker2 := func(_ context.Context, flag string) (bool, error) { return flag == "new_feature", nil }
- reg2 := NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).WithFeatureChecker(checker2).Build()
+ reg2 := mustBuild(t, NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).WithFeatureChecker(checker2))
if len(reg2.AvailableTools(context.Background())) != 1 {
t.Error("Tool should be included when enable flag is true and disable flag is false")
}
// Enable flag set, disable flag also set -> excluded (disable wins)
checker3 := func(_ context.Context, _ string) (bool, error) { return true, nil }
- reg3 := NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).WithFeatureChecker(checker3).Build()
+ reg3 := mustBuild(t, NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).WithFeatureChecker(checker3))
if len(reg3.AvailableTools(context.Background())) != 0 {
t.Error("Tool should be excluded when both flags are true (disable wins)")
}
@@ -1086,7 +1193,7 @@ func TestFeatureFlagError(t *testing.T) {
checkerError := func(_ context.Context, _ string) (bool, error) {
return false, fmt.Errorf("simulated error")
}
- reg := NewBuilder().SetTools(tools).WithFeatureChecker(checkerError).Build()
+ reg := mustBuild(t, NewBuilder().SetTools(tools).WithFeatureChecker(checkerError))
available := reg.AvailableTools(context.Background())
if len(available) != 0 {
t.Errorf("Expected 0 tools when checker errors, got %d", len(available))
@@ -1104,7 +1211,7 @@ func TestFeatureFlagResources(t *testing.T) {
}
// Without checker, resource with enable flag should be excluded
- reg := NewBuilder().SetResources(resources).WithToolsets([]string{"all"}).Build()
+ reg := mustBuild(t, NewBuilder().SetResources(resources).WithToolsets([]string{"all"}))
available := reg.AvailableResourceTemplates(context.Background())
if len(available) != 1 {
t.Fatalf("Expected 1 resource without checker, got %d", len(available))
@@ -1112,7 +1219,7 @@ func TestFeatureFlagResources(t *testing.T) {
// With checker returning true, both should be included
checker := func(_ context.Context, _ string) (bool, error) { return true, nil }
- regWithChecker := NewBuilder().SetResources(resources).WithToolsets([]string{"all"}).WithFeatureChecker(checker).Build()
+ regWithChecker := mustBuild(t, NewBuilder().SetResources(resources).WithToolsets([]string{"all"}).WithFeatureChecker(checker))
if len(regWithChecker.AvailableResourceTemplates(context.Background())) != 2 {
t.Errorf("Expected 2 resources with checker, got %d", len(regWithChecker.AvailableResourceTemplates(context.Background())))
}
@@ -1129,7 +1236,7 @@ func TestFeatureFlagPrompts(t *testing.T) {
}
// Without checker, prompt with enable flag should be excluded
- reg := NewBuilder().SetPrompts(prompts).WithToolsets([]string{"all"}).Build()
+ reg := mustBuild(t, NewBuilder().SetPrompts(prompts).WithToolsets([]string{"all"}))
available := reg.AvailablePrompts(context.Background())
if len(available) != 1 {
t.Fatalf("Expected 1 prompt without checker, got %d", len(available))
@@ -1137,7 +1244,7 @@ func TestFeatureFlagPrompts(t *testing.T) {
// With checker returning true, both should be included
checker := func(_ context.Context, _ string) (bool, error) { return true, nil }
- regWithChecker := NewBuilder().SetPrompts(prompts).WithToolsets([]string{"all"}).WithFeatureChecker(checker).Build()
+ regWithChecker := mustBuild(t, NewBuilder().SetPrompts(prompts).WithToolsets([]string{"all"}).WithFeatureChecker(checker))
if len(regWithChecker.AvailablePrompts(context.Background())) != 2 {
t.Errorf("Expected 2 prompts with checker, got %d", len(regWithChecker.AvailablePrompts(context.Background())))
}
@@ -1220,7 +1327,7 @@ func TestServerToolEnabled(t *testing.T) {
tool := mockTool("test_tool", "toolset1", true)
tool.Enabled = tt.enabledFunc
- reg := NewBuilder().SetTools([]ServerTool{tool}).WithToolsets([]string{"all"}).Build()
+ reg := mustBuild(t, NewBuilder().SetTools([]ServerTool{tool}).WithToolsets([]string{"all"}))
available := reg.AvailableTools(context.Background())
if len(available) != tt.expectedCount {
@@ -1252,7 +1359,7 @@ func TestServerToolEnabledWithContext(t *testing.T) {
return user != nil && user.(string) == "authorized", nil
}
- reg := NewBuilder().SetTools([]ServerTool{tool}).WithToolsets([]string{"all"}).Build()
+ reg := mustBuild(t, NewBuilder().SetTools([]ServerTool{tool}).WithToolsets([]string{"all"}))
// Without user in context - tool should be excluded
available := reg.AvailableTools(context.Background())
@@ -1288,11 +1395,10 @@ func TestBuilderWithFilter(t *testing.T) {
return tool.Tool.Name != "tool2", nil
}
- reg := NewBuilder().
+ reg := mustBuild(t, NewBuilder().
SetTools(tools).
WithToolsets([]string{"all"}).
- WithFilter(filter).
- Build()
+ WithFilter(filter))
available := reg.AvailableTools(context.Background())
if len(available) != 2 {
@@ -1324,12 +1430,11 @@ func TestBuilderWithMultipleFilters(t *testing.T) {
return tool.Tool.Name != "tool3", nil
}
- reg := NewBuilder().
+ reg := mustBuild(t, NewBuilder().
SetTools(tools).
WithToolsets([]string{"all"}).
WithFilter(filter1).
- WithFilter(filter2).
- Build()
+ WithFilter(filter2))
available := reg.AvailableTools(context.Background())
if len(available) != 2 {
@@ -1359,11 +1464,10 @@ func TestBuilderFilterError(t *testing.T) {
return false, fmt.Errorf("filter error")
}
- reg := NewBuilder().
+ reg := mustBuild(t, NewBuilder().
SetTools(tools).
WithToolsets([]string{"all"}).
- WithFilter(filter).
- Build()
+ WithFilter(filter))
available := reg.AvailableTools(context.Background())
if len(available) != 0 {
@@ -1389,11 +1493,10 @@ func TestBuilderFilterWithContext(t *testing.T) {
return true, nil
}
- reg := NewBuilder().
+ reg := mustBuild(t, NewBuilder().
SetTools(tools).
WithToolsets([]string{"all"}).
- WithFilter(filter).
- Build()
+ WithFilter(filter))
// With public scope - private_tool should be excluded
ctxPublic := context.WithValue(context.Background(), scopeKey, "public")
@@ -1422,10 +1525,9 @@ func TestEnabledAndFeatureFlagInteraction(t *testing.T) {
}
// Feature flag not enabled - tool should be excluded despite Enabled returning true
- reg1 := NewBuilder().
+ reg1 := mustBuild(t, NewBuilder().
SetTools([]ServerTool{tool}).
- WithToolsets([]string{"all"}).
- Build()
+ WithToolsets([]string{"all"}))
available1 := reg1.AvailableTools(context.Background())
if len(available1) != 0 {
t.Error("Tool should be excluded when feature flag is not enabled")
@@ -1435,11 +1537,10 @@ func TestEnabledAndFeatureFlagInteraction(t *testing.T) {
checker := func(_ context.Context, flag string) (bool, error) {
return flag == "my_feature", nil
}
- reg2 := NewBuilder().
+ reg2 := mustBuild(t, NewBuilder().
SetTools([]ServerTool{tool}).
WithToolsets([]string{"all"}).
- WithFeatureChecker(checker).
- Build()
+ WithFeatureChecker(checker))
available2 := reg2.AvailableTools(context.Background())
if len(available2) != 1 {
t.Error("Tool should be included when both Enabled and feature flag pass")
@@ -1449,11 +1550,10 @@ func TestEnabledAndFeatureFlagInteraction(t *testing.T) {
tool.Enabled = func(_ context.Context) (bool, error) {
return false, nil
}
- reg3 := NewBuilder().
+ reg3 := mustBuild(t, NewBuilder().
SetTools([]ServerTool{tool}).
WithToolsets([]string{"all"}).
- WithFeatureChecker(checker).
- Build()
+ WithFeatureChecker(checker))
available3 := reg3.AvailableTools(context.Background())
if len(available3) != 0 {
t.Error("Tool should be excluded when Enabled returns false")
@@ -1471,11 +1571,10 @@ func TestEnabledAndBuilderFilterInteraction(t *testing.T) {
return false, nil
}
- reg := NewBuilder().
+ reg := mustBuild(t, NewBuilder().
SetTools([]ServerTool{tool}).
WithToolsets([]string{"all"}).
- WithFilter(filter).
- Build()
+ WithFilter(filter))
available := reg.AvailableTools(context.Background())
if len(available) != 0 {
@@ -1499,12 +1598,11 @@ func TestAllFiltersInteraction(t *testing.T) {
}
// All conditions pass - tool should be included
- reg := NewBuilder().
+ reg := mustBuild(t, NewBuilder().
SetTools([]ServerTool{tool}).
WithToolsets([]string{"all"}).
WithFeatureChecker(checker).
- WithFilter(filter).
- Build()
+ WithFilter(filter))
available := reg.AvailableTools(context.Background())
if len(available) != 1 {
@@ -1516,12 +1614,11 @@ func TestAllFiltersInteraction(t *testing.T) {
return false, nil
}
- reg2 := NewBuilder().
+ reg2 := mustBuild(t, NewBuilder().
SetTools([]ServerTool{tool}).
WithToolsets([]string{"all"}).
WithFeatureChecker(checker).
- WithFilter(filterFalse).
- Build()
+ WithFilter(filterFalse))
available2 := reg2.AvailableTools(context.Background())
if len(available2) != 0 {
@@ -1540,11 +1637,10 @@ func TestFilteredTools(t *testing.T) {
return tool.Tool.Name == "tool1", nil
}
- reg := NewBuilder().
+ reg := mustBuild(t, NewBuilder().
SetTools(tools).
WithToolsets([]string{"all"}).
- WithFilter(filter).
- Build()
+ WithFilter(filter))
filtered, err := reg.FilteredTools(context.Background())
if err != nil {
@@ -1567,11 +1663,10 @@ func TestFilteredToolsMatchesAvailableTools(t *testing.T) {
mockTool("tool3", "toolset2", true),
}
- reg := NewBuilder().
+ reg := mustBuild(t, NewBuilder().
SetTools(tools).
WithToolsets([]string{"toolset1"}).
- WithReadOnly(true).
- Build()
+ WithReadOnly(true))
ctx := context.Background()
filtered, err := reg.FilteredTools(ctx)
@@ -1621,13 +1716,12 @@ func TestFilteringOrder(t *testing.T) {
return true, nil
}
- reg := NewBuilder().
+ reg := mustBuild(t, NewBuilder().
SetTools([]ServerTool{tool}).
WithToolsets([]string{"all"}).
WithReadOnly(true). // This will exclude the tool (it's not read-only)
WithFeatureChecker(checker).
- WithFilter(filter).
- Build()
+ WithFilter(filter))
_ = reg.AvailableTools(context.Background())
@@ -1643,3 +1737,98 @@ 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 := mustBuild(t, NewBuilder().
+ SetTools(tools).
+ WithToolsets([]string{"all"}))
+ 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 := mustBuild(t, NewBuilder().
+ SetTools(tools).
+ WithToolsets([]string{"all"}).
+ WithFeatureChecker(checker))
+ 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 := mustBuild(t, NewBuilder().
+ SetTools(tools).
+ WithDeprecatedAliases(deprecatedAliases).
+ WithToolsets([]string{}). // No toolsets enabled
+ WithTools([]string{"old_tool"})) // Explicitly request old tool
+ 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 := mustBuild(t, NewBuilder().
+ SetTools(tools).
+ WithDeprecatedAliases(deprecatedAliases).
+ WithToolsets([]string{}). // No toolsets enabled
+ WithTools([]string{"old_tool"}). // Request old tool name
+ WithFeatureChecker(checker))
+ 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..752a4c2bd 100644
--- a/pkg/inventory/server_tool.go
+++ b/pkg/inventory/server_tool.go
@@ -31,6 +31,9 @@ type ToolsetMetadata struct {
// Use the base name without size suffix, e.g., "repo" not "repo-16".
// See https://primer.style/foundations/icons for available icons.
Icon string
+ // InstructionsFunc optionally returns instructions for this toolset.
+ // It receives the inventory so it can check what other toolsets are enabled.
+ InstructionsFunc func(inv *Inventory) string
}
// Icons returns MCP Icon objects for this toolset, or nil if no icon is set.
@@ -70,6 +73,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/octicons/octicons.go b/pkg/octicons/octicons.go
index c6b92c47b..5954a8c22 100644
--- a/pkg/octicons/octicons.go
+++ b/pkg/octicons/octicons.go
@@ -62,6 +62,9 @@ func DataURI(name string, theme Theme) string {
// Icons are embedded as 24x24 PNG data URIs for offline use and faster loading.
// The name should be the base octicon name without size suffix (e.g., "repo" not "repo-16").
// See https://primer.style/foundations/icons for available icons.
+//
+// Note: The Sizes field is omitted for backward compatibility with older MCP clients
+// that expect it to be a string rather than an array per the 2025-11-25 MCP spec.
func Icons(name string) []mcp.Icon {
if name == "" {
return nil
@@ -70,14 +73,12 @@ func Icons(name string) []mcp.Icon {
{
Source: DataURI(name, ThemeLight),
MIMEType: "image/png",
- Sizes: []string{"24x24"},
- Theme: string(ThemeLight),
+ Theme: mcp.IconThemeLight,
},
{
Source: DataURI(name, ThemeDark),
MIMEType: "image/png",
- Sizes: []string{"24x24"},
- Theme: string(ThemeDark),
+ Theme: mcp.IconThemeDark,
},
}
}
diff --git a/pkg/octicons/octicons_test.go b/pkg/octicons/octicons_test.go
index f60f7192e..078eb744f 100644
--- a/pkg/octicons/octicons_test.go
+++ b/pkg/octicons/octicons_test.go
@@ -4,6 +4,7 @@ import (
"strings"
"testing"
+ "github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/stretchr/testify/assert"
)
@@ -86,14 +87,14 @@ func TestIcons(t *testing.T) {
// Verify first icon is light theme
assert.Equal(t, DataURI(tc.icon, ThemeLight), result[0].Source)
assert.Equal(t, "image/png", result[0].MIMEType)
- assert.Equal(t, []string{"24x24"}, result[0].Sizes)
- assert.Equal(t, "light", result[0].Theme)
+ assert.Empty(t, result[0].Sizes) // Sizes field omitted for backward compatibility
+ assert.Equal(t, mcp.IconThemeLight, result[0].Theme)
// Verify second icon is dark theme
assert.Equal(t, DataURI(tc.icon, ThemeDark), result[1].Source)
assert.Equal(t, "image/png", result[1].MIMEType)
- assert.Equal(t, []string{"24x24"}, result[1].Sizes)
- assert.Equal(t, "dark", result[1].Theme)
+ assert.Empty(t, result[1].Sizes) // Sizes field omitted for backward compatibility
+ assert.Equal(t, mcp.IconThemeDark, result[1].Theme)
})
}
}
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..b37245503
--- /dev/null
+++ b/pkg/scopes/fetcher.go
@@ -0,0 +1,137 @@
+package scopes
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+
+ "github.com/github/github-mcp-server/pkg/http/headers"
+ "github.com/github/github-mcp-server/pkg/utils"
+)
+
+// 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 utils.APIHostResolver
+}
+
+type FetcherInterface interface {
+ FetchTokenScopes(ctx context.Context, token string) ([]string, error)
+}
+
+// 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 utils.APIHostResolver
+}
+
+// NewFetcher creates a new scope fetcher with the given options.
+func NewFetcher(apiHost utils.APIHostResolver, opts FetcherOptions) *Fetcher {
+ client := opts.HTTPClient
+ if client == nil {
+ client = &http.Client{Timeout: DefaultFetchTimeout}
+ }
+
+ 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) {
+ apiHostURL, err := f.apiHost.BaseRESTURL(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get API host URL: %w", err)
+ }
+
+ // Use a lightweight endpoint that requires authentication
+ endpoint, err := url.JoinPath(apiHostURL.String(), "/")
+ 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(headers.AuthorizationHeader, "Bearer "+token)
+ req.Header.Set(headers.AcceptHeader, "application/vnd.github+json")
+ req.Header.Set(headers.GitHubAPIVersionHeader, "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) {
+ apiHost, err := utils.NewAPIHost("https://api.github.com/")
+ if err != nil {
+ return nil, fmt.Errorf("failed to create default API host: %w", err)
+ }
+
+ return NewFetcher(apiHost, 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 string, apiHost utils.APIHostResolver) ([]string, error) {
+ return NewFetcher(apiHost, FetcherOptions{}).FetchTokenScopes(ctx, token)
+}
diff --git a/pkg/scopes/fetcher_test.go b/pkg/scopes/fetcher_test.go
new file mode 100644
index 000000000..2d887d7a8
--- /dev/null
+++ b/pkg/scopes/fetcher_test.go
@@ -0,0 +1,234 @@
+package scopes
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+type testAPIHostResolver struct {
+ baseURL string
+}
+
+func (t testAPIHostResolver) BaseRESTURL(_ context.Context) (*url.URL, error) {
+ return url.Parse(t.baseURL)
+}
+func (t testAPIHostResolver) GraphqlURL(_ context.Context) (*url.URL, error) {
+ return nil, nil
+}
+func (t testAPIHostResolver) UploadURL(_ context.Context) (*url.URL, error) {
+ return nil, nil
+}
+func (t testAPIHostResolver) RawURL(_ context.Context) (*url.URL, error) {
+ return nil, nil
+}
+
+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()
+ apiHost := testAPIHostResolver{baseURL: server.URL}
+ fetcher := NewFetcher(apiHost, FetcherOptions{})
+
+ 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) {
+ apiHost := testAPIHostResolver{baseURL: "https://api.github.com"}
+ fetcher := NewFetcher(apiHost, FetcherOptions{})
+
+ // Verify default API host is set
+ apiURL, err := fetcher.apiHost.BaseRESTURL(context.Background())
+ require.NoError(t, err)
+ assert.Equal(t, "https://api.github.com", apiURL.String())
+
+ // 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}
+
+ apiHost := testAPIHostResolver{baseURL: "https://api.github.com"}
+ fetcher := NewFetcher(apiHost, FetcherOptions{
+ HTTPClient: customClient,
+ })
+
+ assert.Equal(t, customClient, fetcher.client)
+}
+
+func TestFetcher_CustomAPIHost(t *testing.T) {
+ apiHost := testAPIHostResolver{baseURL: "https://api.github.enterprise.com"}
+ fetcher := NewFetcher(apiHost, FetcherOptions{})
+
+ apiURL, err := fetcher.apiHost.BaseRESTURL(context.Background())
+ require.NoError(t, err)
+ assert.Equal(t, "https://api.github.enterprise.com", apiURL.String())
+}
+
+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()
+
+ apiHost := testAPIHostResolver{baseURL: server.URL}
+ fetcher := NewFetcher(apiHost, FetcherOptions{})
+
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel() // Cancel immediately
+
+ _, err := fetcher.FetchTokenScopes(ctx, "test-token")
+ require.Error(t, err)
+}
diff --git a/pkg/scopes/map.go b/pkg/scopes/map.go
new file mode 100644
index 000000000..3c9833834
--- /dev/null
+++ b/pkg/scopes/map.go
@@ -0,0 +1,129 @@
+package scopes
+
+import "github.com/github/github-mcp-server/pkg/inventory"
+
+// ToolScopeMap maps tool names to their scope requirements.
+type ToolScopeMap map[string]*ToolScopeInfo
+
+// ToolScopeInfo contains scope information for a single tool.
+type ToolScopeInfo struct {
+ // RequiredScopes contains the scopes that are directly required by this tool.
+ RequiredScopes []string
+
+ // AcceptedScopes contains all scopes that satisfy the requirements (including parent scopes).
+ AcceptedScopes []string
+}
+
+// globalToolScopeMap is populated from inventory when SetToolScopeMapFromInventory is called
+var globalToolScopeMap ToolScopeMap
+
+// SetToolScopeMapFromInventory builds and stores a tool scope map from an inventory.
+// This should be called after building the inventory to make scopes available for middleware.
+func SetToolScopeMapFromInventory(inv *inventory.Inventory) {
+ globalToolScopeMap = GetToolScopeMapFromInventory(inv)
+}
+
+// SetGlobalToolScopeMap sets the global tool scope map directly.
+// This is useful for testing when you don't have a full inventory.
+func SetGlobalToolScopeMap(m ToolScopeMap) {
+ globalToolScopeMap = m
+}
+
+// GetToolScopeMap returns the global tool scope map.
+// Returns an empty map if SetToolScopeMapFromInventory hasn't been called yet.
+func GetToolScopeMap() (ToolScopeMap, error) {
+ if globalToolScopeMap == nil {
+ return make(ToolScopeMap), nil
+ }
+ return globalToolScopeMap, nil
+}
+
+// GetToolScopeInfo returns scope information for a specific tool from the global scope map.
+func GetToolScopeInfo(toolName string) (*ToolScopeInfo, error) {
+ m, err := GetToolScopeMap()
+ if err != nil {
+ return nil, err
+ }
+ return m[toolName], nil
+}
+
+// GetToolScopeMapFromInventory builds a tool scope map from an inventory.
+// This extracts scope information from ServerTool.RequiredScopes and ServerTool.AcceptedScopes.
+func GetToolScopeMapFromInventory(inv *inventory.Inventory) ToolScopeMap {
+ result := make(ToolScopeMap)
+
+ // Get all tools from the inventory (both enabled and disabled)
+ // We need all tools for scope checking purposes
+ allTools := inv.AllTools()
+ for i := range allTools {
+ tool := &allTools[i]
+ if len(tool.RequiredScopes) > 0 || len(tool.AcceptedScopes) > 0 {
+ result[tool.Tool.Name] = &ToolScopeInfo{
+ RequiredScopes: tool.RequiredScopes,
+ AcceptedScopes: tool.AcceptedScopes,
+ }
+ }
+ }
+
+ return result
+}
+
+// HasAcceptedScope checks if any of the provided user scopes satisfy the tool's requirements.
+func (t *ToolScopeInfo) HasAcceptedScope(userScopes ...string) bool {
+ if t == nil || len(t.AcceptedScopes) == 0 {
+ return true // No scopes required
+ }
+
+ userScopeSet := make(map[string]bool)
+ for _, scope := range userScopes {
+ userScopeSet[scope] = true
+ }
+
+ for _, scope := range t.AcceptedScopes {
+ if userScopeSet[scope] {
+ return true
+ }
+ }
+ return false
+}
+
+// MissingScopes returns the required scopes that are not present in the user's scopes.
+func (t *ToolScopeInfo) MissingScopes(userScopes ...string) []string {
+ if t == nil || len(t.RequiredScopes) == 0 {
+ return nil
+ }
+
+ // Create a set of user scopes for O(1) lookup
+ userScopeSet := make(map[string]bool, len(userScopes))
+ for _, s := range userScopes {
+ userScopeSet[s] = true
+ }
+
+ // Check if any accepted scope is present
+ hasAccepted := false
+ for _, scope := range t.AcceptedScopes {
+ if userScopeSet[scope] {
+ hasAccepted = true
+ break
+ }
+ }
+
+ if hasAccepted {
+ return nil // User has sufficient scopes
+ }
+
+ // Return required scopes as the minimum needed
+ missing := make([]string, len(t.RequiredScopes))
+ copy(missing, t.RequiredScopes)
+ return missing
+}
+
+// GetRequiredScopesSlice returns the required scopes as a slice of strings.
+func (t *ToolScopeInfo) GetRequiredScopesSlice() []string {
+ if t == nil {
+ return nil
+ }
+ scopes := make([]string, len(t.RequiredScopes))
+ copy(scopes, t.RequiredScopes)
+ return scopes
+}
diff --git a/pkg/scopes/map_test.go b/pkg/scopes/map_test.go
new file mode 100644
index 000000000..5f33cdda2
--- /dev/null
+++ b/pkg/scopes/map_test.go
@@ -0,0 +1,194 @@
+package scopes
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestGetToolScopeMap(t *testing.T) {
+ // Reset and set up a test map
+ SetGlobalToolScopeMap(ToolScopeMap{
+ "test_tool": &ToolScopeInfo{
+ RequiredScopes: []string{"read:org"},
+ AcceptedScopes: []string{"read:org", "write:org", "admin:org"},
+ },
+ })
+
+ m, err := GetToolScopeMap()
+ require.NoError(t, err)
+ require.NotNil(t, m)
+ require.Greater(t, len(m), 0, "expected at least one tool in the scope map")
+
+ testTool, ok := m["test_tool"]
+ require.True(t, ok, "expected test_tool to be in the scope map")
+ assert.Contains(t, testTool.RequiredScopes, "read:org")
+ assert.Contains(t, testTool.AcceptedScopes, "read:org")
+ assert.Contains(t, testTool.AcceptedScopes, "admin:org")
+}
+
+func TestGetToolScopeInfo(t *testing.T) {
+ // Set up test scope map
+ SetGlobalToolScopeMap(ToolScopeMap{
+ "search_orgs": &ToolScopeInfo{
+ RequiredScopes: []string{"read:org"},
+ AcceptedScopes: []string{"read:org", "write:org", "admin:org"},
+ },
+ })
+
+ info, err := GetToolScopeInfo("search_orgs")
+ require.NoError(t, err)
+ require.NotNil(t, info)
+
+ // Non-existent tool should return nil
+ info, err = GetToolScopeInfo("nonexistent_tool")
+ require.NoError(t, err)
+ assert.Nil(t, info)
+}
+
+func TestToolScopeInfo_HasAcceptedScope(t *testing.T) {
+ testCases := []struct {
+ name string
+ scopeInfo *ToolScopeInfo
+ userScopes []string
+ expected bool
+ }{
+ {
+ name: "has exact required scope",
+ scopeInfo: &ToolScopeInfo{
+ RequiredScopes: []string{"read:org"},
+ AcceptedScopes: []string{"read:org", "write:org", "admin:org"},
+ },
+ userScopes: []string{"read:org"},
+ expected: true,
+ },
+ {
+ name: "has parent scope (admin:org grants read:org)",
+ scopeInfo: &ToolScopeInfo{
+ RequiredScopes: []string{"read:org"},
+ AcceptedScopes: []string{"read:org", "write:org", "admin:org"},
+ },
+ userScopes: []string{"admin:org"},
+ expected: true,
+ },
+ {
+ name: "has parent scope (write:org grants read:org)",
+ scopeInfo: &ToolScopeInfo{
+ RequiredScopes: []string{"read:org"},
+ AcceptedScopes: []string{"read:org", "write:org", "admin:org"},
+ },
+ userScopes: []string{"write:org"},
+ expected: true,
+ },
+ {
+ name: "missing required scope",
+ scopeInfo: &ToolScopeInfo{
+ RequiredScopes: []string{"read:org"},
+ AcceptedScopes: []string{"read:org", "write:org", "admin:org"},
+ },
+ userScopes: []string{"repo"},
+ expected: false,
+ },
+ {
+ name: "no scope required",
+ scopeInfo: &ToolScopeInfo{
+ RequiredScopes: []string{},
+ AcceptedScopes: []string{},
+ },
+ userScopes: []string{},
+ expected: true,
+ },
+ {
+ name: "nil scope info",
+ scopeInfo: nil,
+ userScopes: []string{},
+ expected: true,
+ },
+ {
+ name: "repo scope for tool requiring repo",
+ scopeInfo: &ToolScopeInfo{
+ RequiredScopes: []string{"repo"},
+ AcceptedScopes: []string{"repo"},
+ },
+ userScopes: []string{"repo"},
+ expected: true,
+ },
+ {
+ name: "missing repo scope",
+ scopeInfo: &ToolScopeInfo{
+ RequiredScopes: []string{"repo"},
+ AcceptedScopes: []string{"repo"},
+ },
+ userScopes: []string{"public_repo"},
+ expected: false,
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ result := tc.scopeInfo.HasAcceptedScope(tc.userScopes...)
+ assert.Equal(t, tc.expected, result)
+ })
+ }
+}
+
+func TestToolScopeInfo_MissingScopes(t *testing.T) {
+ testCases := []struct {
+ name string
+ scopeInfo *ToolScopeInfo
+ userScopes []string
+ expectedLen int
+ expectedScopes []string
+ }{
+ {
+ name: "has required scope - no missing",
+ scopeInfo: &ToolScopeInfo{
+ RequiredScopes: []string{"read:org"},
+ AcceptedScopes: []string{"read:org", "write:org", "admin:org"},
+ },
+ userScopes: []string{"read:org"},
+ expectedLen: 0,
+ expectedScopes: nil,
+ },
+ {
+ name: "missing scope",
+ scopeInfo: &ToolScopeInfo{
+ RequiredScopes: []string{"read:org"},
+ AcceptedScopes: []string{"read:org", "write:org", "admin:org"},
+ },
+ userScopes: []string{"repo"},
+ expectedLen: 1,
+ expectedScopes: []string{"read:org"},
+ },
+ {
+ name: "no scope required - no missing",
+ scopeInfo: &ToolScopeInfo{
+ RequiredScopes: []string{},
+ AcceptedScopes: []string{},
+ },
+ userScopes: []string{},
+ expectedLen: 0,
+ expectedScopes: nil,
+ },
+ {
+ name: "nil scope info - no missing",
+ scopeInfo: nil,
+ userScopes: []string{},
+ expectedLen: 0,
+ expectedScopes: nil,
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ missing := tc.scopeInfo.MissingScopes(tc.userScopes...)
+ assert.Len(t, missing, tc.expectedLen)
+ if tc.expectedScopes != nil {
+ for _, expected := range tc.expectedScopes {
+ assert.Contains(t, missing, expected)
+ }
+ }
+ })
+ }
+}
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/pkg/tooldiscovery/search.go b/pkg/tooldiscovery/search.go
new file mode 100644
index 000000000..e7adc029b
--- /dev/null
+++ b/pkg/tooldiscovery/search.go
@@ -0,0 +1,314 @@
+package tooldiscovery
+
+import (
+ "sort"
+ "strings"
+
+ "github.com/google/jsonschema-go/jsonschema"
+ "github.com/lithammer/fuzzysearch/fuzzy"
+ "github.com/modelcontextprotocol/go-sdk/mcp"
+)
+
+type SearchResult struct {
+ Tool mcp.Tool `json:"tool"`
+ Score float64 `json:"score"`
+ MatchedIn []string `json:"matchedIn"` // Signals that contributed to scoring (e.g. name:token, description, parameter:token).
+}
+
+const (
+ DefaultMaxSearchResults = 3
+
+ // Scoring weights used by scoreTool.
+ substringMatchScore = 5
+ exactTokensMatchScore = 2.5
+ descriptionMatchScore = 2
+ prefixMatchScore = 1.5
+ parameterMatchScore = 1
+)
+
+// SearchOptions configures search behavior.
+type SearchOptions struct {
+ MaxResults int `json:"maxResults"` // Maximum number of results to return (default: 3)
+}
+
+// Search returns the most relevant tools for a free-text query.
+//
+// Prefer using SearchTools and passing an explicit tool list. This function is
+// kept for API compatibility and currently searches an empty tool set.
+func Search(query string, options ...SearchOptions) ([]SearchResult, error) {
+ return SearchTools(nil, query, options...)
+}
+
+// SearchTools is like Search, but searches across the provided tool list.
+//
+// Matching uses a weighted combination of:
+// - tool name matches (strongest)
+// - description matches
+// - input parameter name matches (JSON schema property names)
+// - fuzzy similarity as a tie-breaker
+//
+// Empty or whitespace-only queries return (nil, nil).
+func SearchTools(tools []mcp.Tool, query string, options ...SearchOptions) ([]SearchResult, error) {
+ maxResults := getMaxResults(options)
+
+ query = strings.TrimSpace(query)
+ if query == "" {
+ return nil, nil
+ }
+
+ queryLower := strings.ToLower(query)
+ queryTokens := strings.Fields(queryLower)
+ normalizedQueryCompact := strings.ReplaceAll(strings.ReplaceAll(queryLower, " ", ""), "_", "")
+
+ results := make([]SearchResult, 0, len(tools))
+ for _, tool := range tools {
+ score, matchedIn := scoreTool(tool, queryLower, queryTokens, normalizedQueryCompact)
+ results = append(results, SearchResult{
+ Tool: tool,
+ Score: score,
+ MatchedIn: matchedIn,
+ })
+ }
+
+ sort.Slice(results, func(i, j int) bool { return results[i].Score > results[j].Score })
+
+ // Filter out low-relevance results
+ const minScore = 1.0
+ filtered := results[:0]
+ for _, r := range results {
+ if r.Score > minScore {
+ filtered = append(filtered, r)
+ }
+ }
+ results = filtered
+
+ // Limit results
+ if len(results) > maxResults {
+ results = results[:maxResults]
+ }
+
+ return results, nil
+}
+
+// scoreTool assigns a relevance score to a tool for the given query.
+//
+// It combines several signals (substrings, token coverage, and similarity) from:
+// - tool name
+// - tool description
+// - input parameter names (schema property names)
+//
+// MatchedIn records which signals contributed to the score for debugging/tuning.
+func scoreTool(
+ tool mcp.Tool,
+ queryLower string,
+ queryTokens []string,
+ normalizedQueryCompact string,
+) (score float64, matchedIn []string) {
+ nameLower := strings.ToLower(tool.Name)
+ descLower := strings.ToLower(tool.Description)
+
+ normalizedNameCompact := strings.ReplaceAll(nameLower, "_", "")
+ nameTokens := splitTokens(nameLower)
+ propertyNames := lowerInputPropertyNames(tool.InputSchema)
+
+ matches := newMatchTracker(3)
+ score = 0.0
+
+ // Strong boosts for direct substring matches
+ if strings.Contains(nameLower, queryLower) {
+ score += substringMatchScore
+ matches.Add("name:substring")
+ }
+ if strings.HasPrefix(nameLower, queryLower) {
+ score += prefixMatchScore
+ matches.Add("name:prefix")
+ }
+ if normalizedNameCompact == normalizedQueryCompact && len(queryTokens) > 1 {
+ score += exactTokensMatchScore
+ matches.Add("name:exact-tokens")
+ }
+ if strings.Contains(descLower, queryLower) {
+ score += descriptionMatchScore
+ matches.Add("description")
+ }
+
+ for _, prop := range propertyNames {
+ if strings.Contains(prop, queryLower) {
+ score += parameterMatchScore
+ matches.Add("parameter")
+ }
+ }
+
+ matchedTokens := make(map[string]struct{})
+
+ // Token-level matches for multi-word queries
+ for _, token := range queryTokens {
+ if strings.Contains(nameLower, token) {
+ score++
+ matchedTokens[token] = struct{}{}
+ matches.Add("name:token")
+ } else if strings.Contains(descLower, token) {
+ score += 0.6
+ matchedTokens[token] = struct{}{}
+ matches.Add("description:token")
+ }
+
+ for _, prop := range propertyNames {
+ if strings.Contains(prop, token) {
+ // Only credit the first parameter match per token to avoid double-counting
+ score += 0.4
+ matchedTokens[token] = struct{}{}
+ matches.Add("parameter:token")
+ break
+ }
+ }
+ }
+
+ tokenCoverage := float64(len(matchedTokens))
+ score += tokenCoverage * 0.8
+ if len(queryTokens) > 1 && len(matchedTokens) == len(queryTokens) {
+ score += 2 // bonus when all tokens are matched somewhere
+ }
+
+ // Prefer names that cover query tokens directly, with fewer extra tokens
+ nameTokenMatches := 0
+ for _, qt := range queryTokens {
+ for _, nt := range nameTokens {
+ if strings.Contains(nt, qt) {
+ nameTokenMatches++
+ break
+ }
+ }
+ }
+ if nameTokenMatches == len(queryTokens) {
+ score += 4.0 // all tokens present in name tokens
+ if len(nameTokens) == len(queryTokens) {
+ score += 2.0 // exact token count match (e.g., issue_write vs sub_issue_write)
+ }
+ }
+ extraTokens := len(nameTokens) - nameTokenMatches
+ if extraTokens > 0 {
+ score -= float64(extraTokens) * 0.5 // stronger penalty for extra unrelated tokens
+ }
+
+ // Similarity scores to soften ordering among close matches
+ nameSim := normalizedSimilarity(nameLower, queryLower)
+ descSim := normalizedSimilarity(descLower, queryLower)
+
+ var propSim float64
+ for _, prop := range propertyNames {
+ if sim := normalizedSimilarity(prop, queryLower); sim > propSim {
+ propSim = sim
+ }
+ }
+
+ searchText := nameLower + " " + descLower
+ if len(propertyNames) > 0 {
+ searchText += " " + strings.Join(propertyNames, " ")
+ }
+ fuzzySim := normalizedSimilarity(searchText, queryLower)
+
+ score += nameSim * 2
+ score += descSim * 0.8
+ score += propSim * 0.6
+ score += fuzzySim * 0.5
+
+ return score, matches.List()
+}
+
+func getMaxResults(options []SearchOptions) int {
+ maxResults := DefaultMaxSearchResults
+ if len(options) > 0 && options[0].MaxResults > 0 {
+ maxResults = options[0].MaxResults
+ }
+ return maxResults
+}
+
+func lowerInputPropertyNames(inputSchema any) []string {
+ if inputSchema == nil {
+ return nil
+ }
+
+ // From the server, this is commonly a *jsonschema.Schema.
+ if schema, ok := inputSchema.(*jsonschema.Schema); ok {
+ if len(schema.Properties) == 0 {
+ return nil
+ }
+ out := make([]string, 0, len(schema.Properties))
+ for prop := range schema.Properties {
+ out = append(out, strings.ToLower(prop))
+ }
+ return out
+ }
+
+ // From the client (or when unmarshaled), schemas arrive as map[string]any.
+ if schema, ok := inputSchema.(map[string]any); ok {
+ propsAny, ok := schema["properties"]
+ if !ok {
+ return nil
+ }
+ props, ok := propsAny.(map[string]any)
+ if !ok || len(props) == 0 {
+ return nil
+ }
+ out := make([]string, 0, len(props))
+ for prop := range props {
+ out = append(out, strings.ToLower(prop))
+ }
+ return out
+ }
+
+ return nil
+}
+
+type matchTracker struct {
+ list []string
+ seen map[string]struct{}
+}
+
+func newMatchTracker(capacity int) *matchTracker {
+ return &matchTracker{
+ list: make([]string, 0, capacity),
+ seen: make(map[string]struct{}, capacity),
+ }
+}
+
+func (m *matchTracker) Add(part string) {
+ if _, ok := m.seen[part]; ok {
+ return
+ }
+ m.seen[part] = struct{}{}
+ m.list = append(m.list, part)
+}
+
+func (m *matchTracker) List() []string {
+ return m.list
+}
+
+func normalizedSimilarity(a, b string) float64 {
+ if len(a) == 0 || len(b) == 0 {
+ return 0
+ }
+
+ distance := fuzzy.LevenshteinDistance(a, b)
+ maxLen := len(a)
+ if len(b) > maxLen {
+ maxLen = len(b)
+ }
+
+ similarity := 1 - (float64(distance) / float64(maxLen))
+ if similarity < 0 {
+ return 0
+ }
+
+ return similarity
+}
+
+func splitTokens(s string) []string {
+ if s == "" {
+ return nil
+ }
+ return strings.FieldsFunc(s, func(r rune) bool {
+ return r == '_' || r == '-' || r == ' '
+ })
+}
diff --git a/pkg/tooldiscovery/search_test.go b/pkg/tooldiscovery/search_test.go
new file mode 100644
index 000000000..79d6fe8dd
--- /dev/null
+++ b/pkg/tooldiscovery/search_test.go
@@ -0,0 +1,57 @@
+package tooldiscovery
+
+import (
+ "testing"
+
+ "github.com/google/jsonschema-go/jsonschema"
+ "github.com/modelcontextprotocol/go-sdk/mcp"
+ "github.com/stretchr/testify/require"
+)
+
+func TestSearchTools_EmptyQueryReturnsNil(t *testing.T) {
+ results, err := SearchTools([]mcp.Tool{{Name: "issue_list"}}, " ")
+ require.NoError(t, err)
+ require.Nil(t, results)
+}
+
+func TestSearchTools_FindsByName(t *testing.T) {
+ tools := []mcp.Tool{
+ {Name: "issue_list", Description: "List issues"},
+ {Name: "repo_get", Description: "Get repository"},
+ }
+
+ results, err := SearchTools(tools, "issue", SearchOptions{MaxResults: 10})
+ require.NoError(t, err)
+ require.NotEmpty(t, results)
+ require.Equal(t, "issue_list", results[0].Tool.Name)
+}
+
+func TestSearchTools_FindsByParameterName_JSONSchema(t *testing.T) {
+ tools := []mcp.Tool{
+ {
+ Name: "unrelated_tool",
+ Description: "does something else",
+ InputSchema: &jsonschema.Schema{Properties: map[string]*jsonschema.Schema{"owner": {}}},
+ },
+ }
+
+ results, err := SearchTools(tools, "owner", SearchOptions{MaxResults: 10})
+ require.NoError(t, err)
+ require.NotEmpty(t, results)
+ require.Equal(t, "unrelated_tool", results[0].Tool.Name)
+}
+
+func TestSearchTools_FindsByParameterName_MapSchema(t *testing.T) {
+ tools := []mcp.Tool{
+ {
+ Name: "unrelated_tool",
+ Description: "does something else",
+ InputSchema: map[string]any{"properties": map[string]any{"repo": map[string]any{}}},
+ },
+ }
+
+ results, err := SearchTools(tools, "repo", SearchOptions{MaxResults: 10})
+ require.NoError(t, err)
+ require.NotEmpty(t, results)
+ require.Equal(t, "unrelated_tool", results[0].Tool.Name)
+}
diff --git a/pkg/utils/api.go b/pkg/utils/api.go
new file mode 100644
index 000000000..a523917de
--- /dev/null
+++ b/pkg/utils/api.go
@@ -0,0 +1,222 @@
+package utils //nolint:revive //TODO: figure out a better name for this package
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+)
+
+type APIHostResolver interface {
+ BaseRESTURL(ctx context.Context) (*url.URL, error)
+ GraphqlURL(ctx context.Context) (*url.URL, error)
+ UploadURL(ctx context.Context) (*url.URL, error)
+ RawURL(ctx context.Context) (*url.URL, error)
+}
+
+type APIHost struct {
+ restURL *url.URL
+ gqlURL *url.URL
+ uploadURL *url.URL
+ rawURL *url.URL
+}
+
+var _ APIHostResolver = APIHost{}
+
+func NewAPIHost(s string) (APIHostResolver, error) {
+ a, err := parseAPIHost(s)
+
+ if err != nil {
+ return nil, err
+ }
+
+ return a, nil
+}
+
+// APIHostResolver implementation
+func (a APIHost) BaseRESTURL(_ context.Context) (*url.URL, error) {
+ return a.restURL, nil
+}
+
+func (a APIHost) GraphqlURL(_ context.Context) (*url.URL, error) {
+ return a.gqlURL, nil
+}
+
+func (a APIHost) UploadURL(_ context.Context) (*url.URL, error) {
+ return a.uploadURL, nil
+}
+
+func (a APIHost) RawURL(_ context.Context) (*url.URL, error) {
+ return a.rawURL, nil
+}
+
+func newDotcomHost() (APIHost, error) {
+ baseRestURL, err := url.Parse("https://api.github.com/")
+ if err != nil {
+ return APIHost{}, fmt.Errorf("failed to parse dotcom REST URL: %w", err)
+ }
+
+ gqlURL, err := url.Parse("https://api.github.com/graphql")
+ if err != nil {
+ return APIHost{}, fmt.Errorf("failed to parse dotcom GraphQL URL: %w", err)
+ }
+
+ uploadURL, err := url.Parse("https://uploads.github.com")
+ if err != nil {
+ return APIHost{}, fmt.Errorf("failed to parse dotcom Upload URL: %w", err)
+ }
+
+ rawURL, err := url.Parse("https://raw.githubusercontent.com/")
+ if err != nil {
+ return APIHost{}, fmt.Errorf("failed to parse dotcom Raw URL: %w", err)
+ }
+
+ return APIHost{
+ restURL: baseRestURL,
+ gqlURL: gqlURL,
+ uploadURL: uploadURL,
+ rawURL: rawURL,
+ }, nil
+}
+
+func newGHECHost(hostname string) (APIHost, error) {
+ u, err := url.Parse(hostname)
+ if err != nil {
+ return APIHost{}, fmt.Errorf("failed to parse GHEC URL: %w", err)
+ }
+
+ // Unsecured GHEC would be an error
+ if u.Scheme == "http" {
+ return APIHost{}, fmt.Errorf("GHEC URL must be HTTPS")
+ }
+
+ restURL, err := url.Parse(fmt.Sprintf("https://api.%s/", u.Hostname()))
+ if err != nil {
+ return APIHost{}, fmt.Errorf("failed to parse GHEC REST URL: %w", err)
+ }
+
+ gqlURL, err := url.Parse(fmt.Sprintf("https://api.%s/graphql", u.Hostname()))
+ if err != nil {
+ return APIHost{}, fmt.Errorf("failed to parse GHEC GraphQL URL: %w", err)
+ }
+
+ uploadURL, err := url.Parse(fmt.Sprintf("https://uploads.%s/", u.Hostname()))
+ if err != nil {
+ return APIHost{}, fmt.Errorf("failed to parse GHEC Upload URL: %w", err)
+ }
+
+ rawURL, err := url.Parse(fmt.Sprintf("https://raw.%s/", u.Hostname()))
+ if err != nil {
+ return APIHost{}, fmt.Errorf("failed to parse GHEC Raw URL: %w", err)
+ }
+
+ return APIHost{
+ restURL: restURL,
+ gqlURL: gqlURL,
+ uploadURL: uploadURL,
+ rawURL: rawURL,
+ }, nil
+}
+
+func newGHESHost(hostname string) (APIHost, error) {
+ u, err := url.Parse(hostname)
+ if err != nil {
+ return APIHost{}, fmt.Errorf("failed to parse GHES URL: %w", err)
+ }
+
+ restURL, err := url.Parse(fmt.Sprintf("%s://%s/api/v3/", u.Scheme, u.Hostname()))
+ if err != nil {
+ return APIHost{}, fmt.Errorf("failed to parse GHES REST URL: %w", err)
+ }
+
+ gqlURL, err := url.Parse(fmt.Sprintf("%s://%s/api/graphql", u.Scheme, u.Hostname()))
+ if err != nil {
+ return APIHost{}, fmt.Errorf("failed to parse GHES GraphQL URL: %w", err)
+ }
+
+ // Check if subdomain isolation is enabled
+ // See https://docs.github.com/en/enterprise-server@3.17/admin/configuring-settings/hardening-security-for-your-enterprise/enabling-subdomain-isolation#about-subdomain-isolation
+ hasSubdomainIsolation := checkSubdomainIsolation(u.Scheme, u.Hostname())
+
+ var uploadURL *url.URL
+ if hasSubdomainIsolation {
+ // With subdomain isolation: https://uploads.hostname/
+ uploadURL, err = url.Parse(fmt.Sprintf("%s://uploads.%s/", u.Scheme, u.Hostname()))
+ } else {
+ // Without subdomain isolation: https://hostname/api/uploads/
+ uploadURL, err = url.Parse(fmt.Sprintf("%s://%s/api/uploads/", u.Scheme, u.Hostname()))
+ }
+ if err != nil {
+ return APIHost{}, fmt.Errorf("failed to parse GHES Upload URL: %w", err)
+ }
+
+ var rawURL *url.URL
+ if hasSubdomainIsolation {
+ // With subdomain isolation: https://raw.hostname/
+ rawURL, err = url.Parse(fmt.Sprintf("%s://raw.%s/", u.Scheme, u.Hostname()))
+ } else {
+ // Without subdomain isolation: https://hostname/raw/
+ rawURL, err = url.Parse(fmt.Sprintf("%s://%s/raw/", u.Scheme, u.Hostname()))
+ }
+ if err != nil {
+ return APIHost{}, fmt.Errorf("failed to parse GHES Raw URL: %w", err)
+ }
+
+ return APIHost{
+ restURL: restURL,
+ gqlURL: gqlURL,
+ uploadURL: uploadURL,
+ rawURL: rawURL,
+ }, nil
+}
+
+// checkSubdomainIsolation detects if GitHub Enterprise Server has subdomain isolation enabled
+// by attempting to ping the raw./_ping endpoint on the subdomain. The raw subdomain must always exist for subdomain isolation.
+func checkSubdomainIsolation(scheme, hostname string) bool {
+ subdomainURL := fmt.Sprintf("%s://raw.%s/_ping", scheme, hostname)
+
+ client := &http.Client{
+ Timeout: 5 * time.Second,
+ // Don't follow redirects - we just want to check if the endpoint exists
+ //nolint:revive // parameters are required by http.Client.CheckRedirect signature
+ CheckRedirect: func(req *http.Request, via []*http.Request) error {
+ return http.ErrUseLastResponse
+ },
+ }
+
+ resp, err := client.Get(subdomainURL)
+ if err != nil {
+ return false
+ }
+ defer resp.Body.Close()
+
+ return resp.StatusCode == http.StatusOK
+}
+
+// Note that this does not handle ports yet, so development environments are out.
+func parseAPIHost(s string) (APIHost, error) {
+ if s == "" {
+ return newDotcomHost()
+ }
+
+ u, err := url.Parse(s)
+ if err != nil {
+ return APIHost{}, fmt.Errorf("could not parse host as URL: %s", s)
+ }
+
+ if u.Scheme == "" {
+ return APIHost{}, fmt.Errorf("host must have a scheme (http or https): %s", s)
+ }
+
+ if strings.HasSuffix(u.Hostname(), "github.com") {
+ return newDotcomHost()
+ }
+
+ if strings.HasSuffix(u.Hostname(), "ghe.com") {
+ return newGHECHost(s)
+ }
+
+ return newGHESHost(s)
+}
diff --git a/pkg/utils/token.go b/pkg/utils/token.go
new file mode 100644
index 000000000..8933fb0bd
--- /dev/null
+++ b/pkg/utils/token.go
@@ -0,0 +1,75 @@
+package utils //nolint:revive //TODO: figure out a better name for this package
+
+import (
+ "fmt"
+ "net/http"
+ "regexp"
+ "strings"
+
+ httpheaders "github.com/github/github-mcp-server/pkg/http/headers"
+ "github.com/github/github-mcp-server/pkg/http/mark"
+)
+
+type TokenType int
+
+const (
+ TokenTypeUnknown TokenType = iota
+ TokenTypePersonalAccessToken
+ TokenTypeFineGrainedPersonalAccessToken
+ TokenTypeOAuthAccessToken
+ TokenTypeUserToServerGitHubAppToken
+ TokenTypeServerToServerGitHubAppToken
+)
+
+var supportedGitHubPrefixes = map[string]TokenType{
+ "ghp_": TokenTypePersonalAccessToken, // Personal access token (classic)
+ "github_pat_": TokenTypeFineGrainedPersonalAccessToken, // Fine-grained personal access token
+ "gho_": TokenTypeOAuthAccessToken, // OAuth access token
+ "ghu_": TokenTypeUserToServerGitHubAppToken, // User access token for a GitHub App
+ "ghs_": TokenTypeServerToServerGitHubAppToken, // Installation access token for a GitHub App (a.k.a. server-to-server token)
+}
+
+var (
+ ErrMissingAuthorizationHeader = fmt.Errorf("%w: missing required Authorization header", mark.ErrBadRequest)
+ ErrBadAuthorizationHeader = fmt.Errorf("%w: Authorization header is badly formatted", mark.ErrBadRequest)
+ ErrUnsupportedAuthorizationHeader = fmt.Errorf("%w: unsupported Authorization header", mark.ErrBadRequest)
+)
+
+// oldPatternRegexp is the regular expression for the old pattern of the token.
+// Until 2021, GitHub API tokens did not have an identifiable prefix. They
+// were 40 characters long and only contained the characters a-f and 0-9.
+var oldPatternRegexp = regexp.MustCompile(`\A[a-f0-9]{40}\z`)
+
+// ParseAuthorizationHeader parses the Authorization header from the HTTP request
+func ParseAuthorizationHeader(req *http.Request) (tokenType TokenType, token string, _ error) {
+ authHeader := req.Header.Get(httpheaders.AuthorizationHeader)
+ if authHeader == "" {
+ return 0, "", ErrMissingAuthorizationHeader
+ }
+
+ switch {
+ // decrypt dotcom token and set it as token
+ case strings.HasPrefix(authHeader, "GitHub-Bearer "):
+ return 0, "", ErrUnsupportedAuthorizationHeader
+ default:
+ // support both "Bearer" and "bearer" to conform to api.github.com
+ if len(authHeader) > 7 && strings.EqualFold(authHeader[:7], "Bearer ") {
+ token = authHeader[7:]
+ } else {
+ token = authHeader
+ }
+ }
+
+ for prefix, tokenType := range supportedGitHubPrefixes {
+ if strings.HasPrefix(token, prefix) {
+ return tokenType, token, nil
+ }
+ }
+
+ matchesOldTokenPattern := oldPatternRegexp.MatchString(token)
+ if matchesOldTokenPattern {
+ return TokenTypePersonalAccessToken, token, nil
+ }
+
+ return 0, "", ErrBadAuthorizationHeader
+}
diff --git a/script/licenses b/script/licenses
index 214efa435..23686315b 100755
--- a/script/licenses
+++ b/script/licenses
@@ -16,10 +16,19 @@
#
# Normally these warnings are packages containing non go code, which may or may not require explicit attribution,
# depending on the license.
-
set -e
-go install github.com/google/go-licenses@latest
+# Pinned version for reproducibility
+# See: https://github.com/cli/cli/pull/11161
+go install github.com/google/go-licenses/v2@v2.0.1
+
+# actions/setup-go does not setup the installed toolchain to be preferred over the system install,
+# which causes go-licenses to raise "Package ... does not have module info" errors in CI.
+# For more information, https://github.com/google/go-licenses/issues/244#issuecomment-1885098633
+if [ "$CI" = "true" ]; then
+ export GOROOT=$(go env GOROOT)
+ export PATH=${GOROOT}/bin:$PATH
+fi
# actions/setup-go does not setup the installed toolchain to be preferred over the system install,
# which causes go-licenses to raise "Package ... does not have module info" errors in CI.
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 e44711943..6028ecfda 100644
--- a/third-party-licenses.darwin.md
+++ b/third-party-licenses.darwin.md
@@ -15,21 +15,20 @@ The following packages are included for the amd64, arm64 architectures.
- [github.com/aymerick/douceur](https://pkg.go.dev/github.com/aymerick/douceur) ([MIT](https://github.com/aymerick/douceur/blob/v0.2.0/LICENSE))
- [github.com/fsnotify/fsnotify](https://pkg.go.dev/github.com/fsnotify/fsnotify) ([BSD-3-Clause](https://github.com/fsnotify/fsnotify/blob/v1.9.0/LICENSE))
- [github.com/github/github-mcp-server](https://pkg.go.dev/github.com/github/github-mcp-server) ([MIT](https://github.com/github/github-mcp-server/blob/HEAD/LICENSE))
+ - [github.com/go-chi/chi/v5](https://pkg.go.dev/github.com/go-chi/chi/v5) ([MIT](https://github.com/go-chi/chi/blob/v5.2.3/LICENSE))
- [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/go-viper/mapstructure/v2](https://pkg.go.dev/github.com/go-viper/mapstructure/v2) ([MIT](https://github.com/go-viper/mapstructure/blob/v2.5.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.3.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/lithammer/fuzzysearch/fuzzy](https://pkg.go.dev/github.com/lithammer/fuzzysearch/fuzzy) ([MIT](https://github.com/lithammer/fuzzysearch/blob/v1.1.8/LICENSE))
- [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-pre.1/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))
- [github.com/sagikazarmark/locafero](https://pkg.go.dev/github.com/sagikazarmark/locafero) ([MIT](https://github.com/sagikazarmark/locafero/blob/v0.11.0/LICENSE))
@@ -38,7 +37,7 @@ The following packages are included for the amd64, arm64 architectures.
- [github.com/sourcegraph/conc](https://pkg.go.dev/github.com/sourcegraph/conc) ([MIT](https://github.com/sourcegraph/conc/blob/5f936abd7ae8/LICENSE))
- [github.com/spf13/afero](https://pkg.go.dev/github.com/spf13/afero) ([Apache-2.0](https://github.com/spf13/afero/blob/v1.15.0/LICENSE.txt))
- [github.com/spf13/cast](https://pkg.go.dev/github.com/spf13/cast) ([MIT](https://github.com/spf13/cast/blob/v1.10.0/LICENSE))
- - [github.com/spf13/cobra](https://pkg.go.dev/github.com/spf13/cobra) ([Apache-2.0](https://github.com/spf13/cobra/blob/v1.10.1/LICENSE.txt))
+ - [github.com/spf13/cobra](https://pkg.go.dev/github.com/spf13/cobra) ([Apache-2.0](https://github.com/spf13/cobra/blob/v1.10.2/LICENSE.txt))
- [github.com/spf13/pflag](https://pkg.go.dev/github.com/spf13/pflag) ([BSD-3-Clause](https://github.com/spf13/pflag/blob/v1.0.10/LICENSE))
- [github.com/spf13/viper](https://pkg.go.dev/github.com/spf13/viper) ([MIT](https://github.com/spf13/viper/blob/v1.21.0/LICENSE))
- [github.com/subosito/gotenv](https://pkg.go.dev/github.com/subosito/gotenv) ([MIT](https://github.com/subosito/gotenv/blob/v1.6.0/LICENSE))
@@ -49,7 +48,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 f5c147d59..3d7b8b3fe 100644
--- a/third-party-licenses.linux.md
+++ b/third-party-licenses.linux.md
@@ -15,21 +15,20 @@ The following packages are included for the 386, amd64, arm64 architectures.
- [github.com/aymerick/douceur](https://pkg.go.dev/github.com/aymerick/douceur) ([MIT](https://github.com/aymerick/douceur/blob/v0.2.0/LICENSE))
- [github.com/fsnotify/fsnotify](https://pkg.go.dev/github.com/fsnotify/fsnotify) ([BSD-3-Clause](https://github.com/fsnotify/fsnotify/blob/v1.9.0/LICENSE))
- [github.com/github/github-mcp-server](https://pkg.go.dev/github.com/github/github-mcp-server) ([MIT](https://github.com/github/github-mcp-server/blob/HEAD/LICENSE))
+ - [github.com/go-chi/chi/v5](https://pkg.go.dev/github.com/go-chi/chi/v5) ([MIT](https://github.com/go-chi/chi/blob/v5.2.3/LICENSE))
- [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/go-viper/mapstructure/v2](https://pkg.go.dev/github.com/go-viper/mapstructure/v2) ([MIT](https://github.com/go-viper/mapstructure/blob/v2.5.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.3.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/lithammer/fuzzysearch/fuzzy](https://pkg.go.dev/github.com/lithammer/fuzzysearch/fuzzy) ([MIT](https://github.com/lithammer/fuzzysearch/blob/v1.1.8/LICENSE))
- [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-pre.1/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))
- [github.com/sagikazarmark/locafero](https://pkg.go.dev/github.com/sagikazarmark/locafero) ([MIT](https://github.com/sagikazarmark/locafero/blob/v0.11.0/LICENSE))
@@ -38,7 +37,7 @@ The following packages are included for the 386, amd64, arm64 architectures.
- [github.com/sourcegraph/conc](https://pkg.go.dev/github.com/sourcegraph/conc) ([MIT](https://github.com/sourcegraph/conc/blob/5f936abd7ae8/LICENSE))
- [github.com/spf13/afero](https://pkg.go.dev/github.com/spf13/afero) ([Apache-2.0](https://github.com/spf13/afero/blob/v1.15.0/LICENSE.txt))
- [github.com/spf13/cast](https://pkg.go.dev/github.com/spf13/cast) ([MIT](https://github.com/spf13/cast/blob/v1.10.0/LICENSE))
- - [github.com/spf13/cobra](https://pkg.go.dev/github.com/spf13/cobra) ([Apache-2.0](https://github.com/spf13/cobra/blob/v1.10.1/LICENSE.txt))
+ - [github.com/spf13/cobra](https://pkg.go.dev/github.com/spf13/cobra) ([Apache-2.0](https://github.com/spf13/cobra/blob/v1.10.2/LICENSE.txt))
- [github.com/spf13/pflag](https://pkg.go.dev/github.com/spf13/pflag) ([BSD-3-Clause](https://github.com/spf13/pflag/blob/v1.0.10/LICENSE))
- [github.com/spf13/viper](https://pkg.go.dev/github.com/spf13/viper) ([MIT](https://github.com/spf13/viper/blob/v1.21.0/LICENSE))
- [github.com/subosito/gotenv](https://pkg.go.dev/github.com/subosito/gotenv) ([MIT](https://github.com/subosito/gotenv/blob/v1.6.0/LICENSE))
@@ -49,7 +48,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 b3de372e7..48bad011e 100644
--- a/third-party-licenses.windows.md
+++ b/third-party-licenses.windows.md
@@ -15,22 +15,21 @@ The following packages are included for the 386, amd64, arm64 architectures.
- [github.com/aymerick/douceur](https://pkg.go.dev/github.com/aymerick/douceur) ([MIT](https://github.com/aymerick/douceur/blob/v0.2.0/LICENSE))
- [github.com/fsnotify/fsnotify](https://pkg.go.dev/github.com/fsnotify/fsnotify) ([BSD-3-Clause](https://github.com/fsnotify/fsnotify/blob/v1.9.0/LICENSE))
- [github.com/github/github-mcp-server](https://pkg.go.dev/github.com/github/github-mcp-server) ([MIT](https://github.com/github/github-mcp-server/blob/HEAD/LICENSE))
+ - [github.com/go-chi/chi/v5](https://pkg.go.dev/github.com/go-chi/chi/v5) ([MIT](https://github.com/go-chi/chi/blob/v5.2.3/LICENSE))
- [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/go-viper/mapstructure/v2](https://pkg.go.dev/github.com/go-viper/mapstructure/v2) ([MIT](https://github.com/go-viper/mapstructure/blob/v2.5.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.3.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/lithammer/fuzzysearch/fuzzy](https://pkg.go.dev/github.com/lithammer/fuzzysearch/fuzzy) ([MIT](https://github.com/lithammer/fuzzysearch/blob/v1.1.8/LICENSE))
- [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-pre.1/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))
- [github.com/sagikazarmark/locafero](https://pkg.go.dev/github.com/sagikazarmark/locafero) ([MIT](https://github.com/sagikazarmark/locafero/blob/v0.11.0/LICENSE))
@@ -39,7 +38,7 @@ The following packages are included for the 386, amd64, arm64 architectures.
- [github.com/sourcegraph/conc](https://pkg.go.dev/github.com/sourcegraph/conc) ([MIT](https://github.com/sourcegraph/conc/blob/5f936abd7ae8/LICENSE))
- [github.com/spf13/afero](https://pkg.go.dev/github.com/spf13/afero) ([Apache-2.0](https://github.com/spf13/afero/blob/v1.15.0/LICENSE.txt))
- [github.com/spf13/cast](https://pkg.go.dev/github.com/spf13/cast) ([MIT](https://github.com/spf13/cast/blob/v1.10.0/LICENSE))
- - [github.com/spf13/cobra](https://pkg.go.dev/github.com/spf13/cobra) ([Apache-2.0](https://github.com/spf13/cobra/blob/v1.10.1/LICENSE.txt))
+ - [github.com/spf13/cobra](https://pkg.go.dev/github.com/spf13/cobra) ([Apache-2.0](https://github.com/spf13/cobra/blob/v1.10.2/LICENSE.txt))
- [github.com/spf13/pflag](https://pkg.go.dev/github.com/spf13/pflag) ([BSD-3-Clause](https://github.com/spf13/pflag/blob/v1.0.10/LICENSE))
- [github.com/spf13/viper](https://pkg.go.dev/github.com/spf13/viper) ([MIT](https://github.com/spf13/viper/blob/v1.21.0/LICENSE))
- [github.com/subosito/gotenv](https://pkg.go.dev/github.com/subosito/gotenv) ([MIT](https://github.com/subosito/gotenv/blob/v1.6.0/LICENSE))
@@ -50,7 +49,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/go-chi/chi/v5/LICENSE b/third-party/github.com/go-chi/chi/v5/LICENSE
new file mode 100644
index 000000000..d99f02ffa
--- /dev/null
+++ b/third-party/github.com/go-chi/chi/v5/LICENSE
@@ -0,0 +1,20 @@
+Copyright (c) 2015-present Peter Kieltyka (https://github.com/pkieltyka), Google Inc.
+
+MIT License
+
+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/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/lithammer/fuzzysearch/fuzzy/LICENSE
similarity index 94%
rename from third-party/github.com/migueleliasweb/go-github-mock/src/mock/LICENSE
rename to third-party/github.com/lithammer/fuzzysearch/fuzzy/LICENSE
index 86d42717d..dee3d1de2 100644
--- a/third-party/github.com/migueleliasweb/go-github-mock/src/mock/LICENSE
+++ b/third-party/github.com/lithammer/fuzzysearch/fuzzy/LICENSE
@@ -1,6 +1,6 @@
-MIT License
+The MIT License (MIT)
-Copyright (c) 2021 Miguel Elias dos Santos
+Copyright (c) 2018 Peter Lithammer
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
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.
]