chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:37:57 +08:00
commit e30f8ba47c
533 changed files with 115926 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
.github
.vscode
script
third-party
.dockerignore
.gitignore
**/*.yml
**/*.yaml
**/*.md
**/*_test.go
LICENSE
+1
View File
@@ -0,0 +1 @@
* @github/github-mcp-server
+30
View File
@@ -0,0 +1,30 @@
---
name: "\U0001F41B Bug report"
about: Report a bug or unexpected behavior while using GitHub MCP Server
title: ''
labels: bug
assignees: ''
---
### Describe the bug
A clear and concise description of what the bug is.
### Affected version
Please run ` docker run -i --rm ghcr.io/github/github-mcp-server ./github-mcp-server --version` and paste the output below
### Steps to reproduce the behavior
1. Type this '...'
2. View the output '....'
3. See error
### Expected vs actual behavior
A clear and concise description of what you expected to happen and what actually happened.
### Logs
Paste any available logs. Redact if needed.
+24
View File
@@ -0,0 +1,24 @@
---
name: "⭐ Submit a feature request"
about: Surface a feature or problem that you think should be solved
title: ''
labels: enhancement
assignees: ''
---
### Describe the feature or problem youd like to solve
A clear and concise description of what the feature or problem is.
### Proposed solution
How will it benefit GitHub MCP Server and its users?
### Example prompts or workflows (for tools/toolsets only)
If it's a new tool or improvement, share 35 example prompts or workflows it would enable. Just enough detail to show the value. Clear, valuable use cases are more likely to get approved.
### Additional context
Add any other context like screenshots or mockups are helpful, if applicable.
@@ -0,0 +1,14 @@
---
name: Insiders Feedback
about: Give feedback related to a GitHub MCP Server Insiders feature
title: "Insiders Feedback: "
labels: ''
assignees: ''
---
Version: Insiders
Feature:
Feedback:
+39
View File
@@ -0,0 +1,39 @@
name: Build UI
description: Restore cached UI HTML artifacts, or set up Node and run script/build-ui on cache miss.
runs:
using: composite
steps:
- name: Cache UI artifacts
id: cache-ui
uses: actions/cache@v5
with:
path: |
pkg/github/ui_dist/get-me.html
pkg/github/ui_dist/issue-write.html
pkg/github/ui_dist/pr-write.html
pkg/github/ui_dist/pr-edit.html
key: ui-dist-v2-${{ hashFiles('ui/package-lock.json', 'ui/package.json', 'ui/index.html', 'ui/tsconfig*.json', 'ui/vite.config.ts', 'ui/src/**', 'ui/scripts/**') }}
enableCrossOsArchive: true
- name: Set up Node.js
if: steps.cache-ui.outputs.cache-hit != 'true'
uses: actions/setup-node@v6
with:
node-version: "20"
cache: npm
cache-dependency-path: ui/package-lock.json
- name: Build UI
if: steps.cache-ui.outputs.cache-hit != 'true'
shell: bash
run: script/build-ui
- name: Report UI cache status
shell: bash
run: |
if [ "${{ steps.cache-ui.outputs.cache-hit }}" = "true" ]; then
echo "UI artifacts restored from cache (skipped build)."
else
echo "UI artifacts rebuilt from source."
fi
+112
View File
@@ -0,0 +1,112 @@
---
name: go-sdk-tool-migrator
description: Agent specializing in migrating MCP tools from mark3labs/mcp-go to modelcontextprotocol/go-sdk
---
# Go SDK Tool Migrator Agent
You are a specialized agent designed to assist developers in migrating MCP tools from the mark3labs/mcp-go library to the modelcontextprotocol/go-sdk. Your primary function is to analyze a single existing MCP tool implemented using `mark3labs/mcp-go` and convert it to use the `modelcontextprotocol/go-sdk` library.
## Migration Process
You should focus on ONLY the toolset you are asked to migrate and its corresponding test file. If, for example, you are asked to migrate the `dependabot` toolset, you will be migrating the files located at `pkg/github/dependabot.go` and `pkg/github/dependabot_test.go`. If there are additional tests or helper functions that fail to work with the new SDK, you should inform me of these issues so that I can address them, or instruct you on how to proceed.
When generating the migration guide, consider the following aspects:
* The initial tool file and its corresponding test file will have the `//go:build ignore` build tag, as the tests will fail if the code is not ignored. The `ignore` build tag should be removed before work begins.
* The import for `github.com/mark3labs/mcp-go/mcp` should be changed to `github.com/modelcontextprotocol/go-sdk/mcp`
* The return type for the tool constructor function should be updated from `mcp.Tool, server.ToolHandlerFunc` to `(mcp.Tool, mcp.ToolHandlerFor[map[string]any, any])`.
* The tool handler function signature should be updated to use generics, changing from `func(ctx context.Context, mcp.CallToolRequest) (*mcp.CallToolResult, error)` to `func(context.Context, *mcp.CallToolRequest, map[string]any) (*mcp.CallToolResult, any, error)`.
* The `RequiredParam`, `RequiredInt`, `RequiredBigInt`, `OptionalParamOK`, `OptionalParam`, `OptionalIntParam`, `OptionalIntParamWithDefault`, `OptionalBoolParamWithDefault`, `OptionalStringArrayParam`, `OptionalBigIntArrayParam` and `OptionalCursorPaginationParams` functions should be changed to use the tool arguments that are now passed as a map in the tool handler function, rather than extracting them from the `mcp.CallToolRequest`.
* `mcp.NewToolResultText`, `mcp.NewToolResultError`, `mcp.NewToolResultErrorFromErr` and `mcp.NewToolResultResource` no longer available in `modelcontextprotocol/go-sdk`. There are a few helper functions available in `pkg/utils/result.go` that can be used to replace these, in the `utils` package.
### Schema Changes
The biggest change when migrating MCP tools from mark3labs/mcp-go to modelcontextprotocol/go-sdk is the way input and output schemas are defined and handled. In `mark3labs/mcp-go`, input and output schemas were often defined using a DSL provided by the library. In `modelcontextprotocol/go-sdk`, schemas are defined using `jsonschema.Schema` structures using `github.com/google/jsonschema-go`, which are more verbose.
When migrating a tool, you will need to convert the existing schema definitions to JSON Schema format. This involves defining the properties, types, and any validation rules using the JSON Schema specification.
#### Example Schema Guide
If we take an example of a tool that has the following input schema in mark3labs/mcp-go:
```go
...
return mcp.NewTool(
"list_dependabot_alerts",
mcp.WithDescription(t("TOOL_LIST_DEPENDABOT_ALERTS_DESCRIPTION", "List dependabot alerts in a GitHub repository.")),
mcp.WithToolAnnotation(mcp.ToolAnnotation{
Title: t("TOOL_LIST_DEPENDABOT_ALERTS_USER_TITLE", "List dependabot alerts"),
ReadOnlyHint: ToBoolPtr(true),
}),
mcp.WithString("owner",
mcp.Required(),
mcp.Description("The owner of the repository."),
),
mcp.WithString("repo",
mcp.Required(),
mcp.Description("The name of the repository."),
),
mcp.WithString("state",
mcp.Description("Filter dependabot alerts by state. Defaults to open"),
mcp.DefaultString("open"),
mcp.Enum("open", "fixed", "dismissed", "auto_dismissed"),
),
mcp.WithString("severity",
mcp.Description("Filter dependabot alerts by severity"),
mcp.Enum("low", "medium", "high", "critical"),
),
),
...
```
The corresponding input schema in modelcontextprotocol/go-sdk would look like this:
```go
...
return mcp.Tool{
Name: "list_dependabot_alerts",
Description: t("TOOL_LIST_DEPENDABOT_ALERTS_DESCRIPTION", "List dependabot alerts in a GitHub repository."),
Annotations: &mcp.ToolAnnotations{
Title: t("TOOL_LIST_DEPENDABOT_ALERTS_USER_TITLE", "List dependabot alerts"),
ReadOnlyHint: true,
},
InputSchema: &jsonschema.Schema{
Type: "object",
Properties: map[string]*jsonschema.Schema{
"owner": {
Type: "string",
Description: "The owner of the repository.",
},
"repo": {
Type: "string",
Description: "The name of the repository.",
},
"state": {
Type: "string",
Description: "Filter dependabot alerts by state. Defaults to open",
Enum: []any{"open", "fixed", "dismissed", "auto_dismissed"},
Default: "open",
},
"severity": {
Type: "string",
Description: "Filter dependabot alerts by severity",
Enum: []any{"low", "medium", "high", "critical"},
},
},
Required: []string{"owner", "repo"},
},
}
```
### Tests
After migrating the tool code and test file, ensure that all tests pass successfully. If any tests fail, review the error messages and adjust the migrated code as necessary to resolve any issues. If you encounter any challenges or need further assistance during the migration process, please let me know.
At the end of your changes, you will continue to have an issue with the `toolsnaps` tests, these validate that the schema has not changed unexpectedly. You can update the snapshots by setting `UPDATE_TOOLSNAPS=true` before running the tests, e.g.:
```bash
UPDATE_TOOLSNAPS=true go test ./...
```
You should however, only update the toolsnaps after confirming that the schema changes are intentional and correct. Some schema changes are unavoidable, such as argument ordering, however the schemas themselves should remain logically equivalent.
+291
View File
@@ -0,0 +1,291 @@
# GitHub MCP Server - Copilot Instructions
## Project Overview
This is the **GitHub MCP Server**, a Model Context Protocol (MCP) server that connects AI tools to GitHub's platform. It enables AI agents to manage repositories, issues, pull requests, workflows, and more through natural language.
**Key Details:**
- **Language:** Go 1.24+ (~38k lines of code)
- **Type:** MCP server application with CLI interface
- **Primary Package:** github-mcp-server (stdio MCP server - **this is the main focus**)
- **Secondary Package:** mcpcurl (testing utility - don't break it, but not the priority)
- **Framework:** Uses modelcontextprotocol/go-sdk for MCP protocol, google/go-github for GitHub API
- **Size:** ~60MB repository, 70 Go files
- **Library Usage:** This repository is also used as a library by the remote server. Functions that could be called by other repositories should be exported (capitalized), even if not required internally. Preserve existing export patterns.
**Code Quality Standards:**
- **Popular Open Source Repository** - High bar for code quality and clarity
- **Comprehension First** - Code must be clear to a wide audience
- **Clean Commits** - Atomic, focused changes with clear messages
- **Structure** - Always maintain or improve, never degrade
- **Code over Comments** - Prefer self-documenting code; comment only when necessary
## Critical Build & Validation Steps
### Required Commands (Run Before Committing)
**ALWAYS run these commands in this exact order before using report_progress or finishing work:**
1. **Format Code:** `script/lint` (runs `gofmt -s -w .` then `golangci-lint`)
2. **Run Tests:** `script/test` (runs `go test -race ./...`)
3. **Update Documentation:** `script/generate-docs` (if you modified MCP tools/toolsets)
**These commands are FAST:** Lint ~1s, Tests ~1s (cached), Build ~1s
### When Modifying MCP Tools/Endpoints
If you change any MCP tool definitions or schemas:
1. Run tests with `UPDATE_TOOLSNAPS=true go test ./...` to update toolsnaps
2. Commit the updated `.snap` files in `pkg/github/__toolsnaps__/`
3. Run `script/generate-docs` to update README.md
4. Toolsnaps document API surface and ensure changes are intentional
### Common Build Commands
```bash
# Download dependencies (rarely needed - usually cached)
go mod download
# Build the server binary
go build -v ./cmd/github-mcp-server
# Run the server
./github-mcp-server stdio
# Run specific package tests
go test ./pkg/github -v
# Run specific test
go test ./pkg/github -run TestGetMe
```
## Project Structure
### Directory Layout
```
.
├── cmd/
│ ├── github-mcp-server/ # Main MCP server entry point (PRIMARY FOCUS)
│ └── mcpcurl/ # MCP testing utility (secondary - don't break it)
├── pkg/ # Public API packages
│ ├── github/ # GitHub API MCP tools implementation
│ │ └── __toolsnaps__/ # Tool schema snapshots (*.snap files)
│ ├── toolsets/ # Toolset configuration & management
│ ├── errors/ # Error handling utilities
│ ├── sanitize/ # HTML/content sanitization
│ ├── log/ # Logging utilities
│ ├── raw/ # Raw data handling
│ ├── buffer/ # Buffer utilities
│ └── translations/ # i18n translation support
├── internal/ # Internal implementation packages
│ ├── ghmcp/ # GitHub MCP server core logic
│ ├── githubv4mock/ # GraphQL API mocking for tests
│ ├── toolsnaps/ # Toolsnap validation system
│ └── profiler/ # Performance profiling
├── e2e/ # End-to-end tests (require GitHub PAT)
├── script/ # Build and maintenance scripts
├── docs/ # Documentation
├── .github/workflows/ # CI/CD workflows
└── [config files] # See below
```
### Key Configuration Files
- **go.mod / go.sum:** Go module dependencies (Go 1.24.0+)
- **.golangci.yml:** Linter configuration (v2 format, ~15 linters enabled)
- **Dockerfile:** Multi-stage build (golang:1.25.8-alpine → distroless)
- **server.json:** MCP server metadata for registry
- **.goreleaser.yaml:** Release automation config
- **.gitignore:** Excludes bin/, dist/, vendor/, *.DS_Store, github-mcp-server binary
### Important Scripts (script/ directory)
- **script/lint** - Runs `gofmt` + `golangci-lint`. **MUST RUN** before committing
- **script/test** - Runs `go test -race ./...` (full test suite)
- **script/generate-docs** - Updates README.md tool documentation. Run after tool changes
- **script/licenses** - Updates third-party license files when dependencies change
- **script/licenses-check** - Validates license compliance (runs in CI)
- **script/get-me** - Quick test script for get_me tool
- **script/get-discussions** - Quick test for discussions
- **script/tag-release** - **NEVER USE THIS** - releases are managed separately
## GitHub Workflows (CI/CD)
All workflows run on push/PR unless noted. Located in `.github/workflows/`:
1. **go.yml** - Build and test on ubuntu/windows/macos. Runs `script/test` and builds binary
2. **lint.yml** - Runs golangci-lint-action v2.5 (GitHub Action) with actions/setup-go stable
3. **docs-check.yml** - Verifies README.md is up-to-date by running generate-docs and checking git diff
4. **code-scanning.yml** - CodeQL security analysis for Go and GitHub Actions
5. **license-check.yml** - Runs `script/licenses-check` to validate compliance
6. **docker-publish.yml** - Publishes container image to ghcr.io
7. **goreleaser.yml** - Creates releases (main branch only)
8. **registry-releaser.yml** - Updates MCP registry
**All of these must pass for PR merge.** If docs-check fails, run `script/generate-docs` and commit changes.
## Testing Guidelines
### Unit Tests
- Use `testify` for assertions (`require` for critical checks, `assert` for non-blocking)
- Tests are in `*_test.go` files alongside implementation (internal tests, not `_test` package)
- Mock GitHub API with `go-github-mock` (REST) or `githubv4mock` (GraphQL)
- Test structure for tools:
1. Test tool snapshot
2. Verify critical schema properties (e.g., ReadOnly annotation)
3. Table-driven behavioral tests
### Toolsnaps (Tool Schema Snapshots)
- Every MCP tool has a JSON schema snapshot in `pkg/github/__toolsnaps__/*.snap`
- Tests fail if current schema differs from snapshot (shows diff)
- To update after intentional changes: `UPDATE_TOOLSNAPS=true go test ./...`
- **MUST commit updated .snap files** - they document API changes
- Missing snapshots cause CI failure
### End-to-End Tests
- Located in `e2e/` directory with `e2e_test.go`
- **Require GitHub PAT token** - you usually cannot run these yourself
- Run with: `GITHUB_MCP_SERVER_E2E_TOKEN=<token> go test -v --tags e2e ./e2e`
- Tests interact with live GitHub API via Docker container
- **Keep e2e tests updated when changing MCP tools**
- **Use only the e2e test style** when modifying tests in this directory
- For debugging: `GITHUB_MCP_SERVER_E2E_DEBUG=true` runs in-process (no Docker)
## Code Style & Linting
### Go Code Requirements
- **gofmt with simplify flag (-s)** - Automatically run by `script/lint`
- **golangci-lint** with these linters enabled:
- bodyclose, gocritic, gosec, makezero, misspell, nakedret, revive
- errcheck, staticcheck, govet, ineffassign, unused
- Exclusions for: third_party/, builtin/, examples/, generated code
### Go Naming Conventions
- **Acronyms in identifiers:** Use `ID` not `Id`, `API` not `Api`, `URL` not `Url`, `HTTP` not `Http`
- Examples: `userID`, `getAPI`, `parseURL`, `HTTPClient`
- This applies to variable names, function names, struct fields, etc.
### Code Patterns
- **Keep changes minimal and focused** on the specific issue being addressed
- **Prefer clarity over cleverness** - code must be understandable by a wide audience
- **Atomic commits** - each commit should be a complete, logical change
- **Maintain or improve structure** - never degrade code organization
- Use table-driven tests for behavioral testing
- Comment sparingly - code should be self-documenting
- Follow standard Go conventions (Effective Go, Go proverbs)
- **Test changes thoroughly** before committing
- Export functions (capitalize) if they could be used by other repos as a library
## Common Development Workflows
### Adding a New MCP Tool
1. Add tool implementation in `pkg/github/` (e.g., `foo_tools.go`)
2. Register tool in appropriate toolset in `pkg/github/` or `pkg/toolsets/`
3. Write unit tests following the tool test pattern
4. Run `UPDATE_TOOLSNAPS=true go test ./...` to create snapshot
5. Run `script/generate-docs` to update README
6. Run `script/lint` and `script/test` before committing
7. If e2e tests are relevant, update `e2e/e2e_test.go` using existing test style
8. Commit code + snapshots + README changes together
### Fixing a Bug
1. Write a failing test that reproduces the bug
2. Fix the bug with minimal changes
3. Verify test passes and existing tests still pass
4. Run `script/lint` and `script/test`
5. If tool schema changed, update toolsnaps (see above)
### Updating Dependencies
1. Update `go.mod` (e.g., `go get -u ./...` or manually)
2. Run `go mod tidy`
3. Run `script/licenses` to update license files
4. Run `script/test` to verify nothing broke
5. Commit go.mod, go.sum, and third-party-licenses* files
## Common Errors & Solutions
### "Documentation is out of date" in CI
**Fix:** Run `script/generate-docs` and commit README.md changes
### Toolsnap mismatch failures
**Fix:** Run `UPDATE_TOOLSNAPS=true go test ./...` and commit updated .snap files
### Lint failures
**Fix:** Run `script/lint` locally - it will auto-format and show issues. Fix manually reported issues.
### License check failures
**Fix:** Run `script/licenses` to regenerate license files after dependency changes
### Test failures after changing a tool
**Likely causes:**
1. Forgot to update toolsnaps - run with `UPDATE_TOOLSNAPS=true`
2. Changed behavior broke existing tests - verify intent and fix tests
3. Schema change not reflected in test - update test expectations
## Environment Variables
- **GITHUB_PERSONAL_ACCESS_TOKEN** - Required for server operation and e2e tests
- **GITHUB_HOST** - For GitHub Enterprise Server (prefix with `https://`)
- **GITHUB_TOOLSETS** - Comma-separated toolset list (overrides --toolsets flag)
- **GITHUB_READ_ONLY** - Set to "1" for read-only mode
- **UPDATE_TOOLSNAPS** - Set to "true" when running tests to update snapshots
- **GITHUB_MCP_SERVER_E2E_TOKEN** - Token for e2e tests
- **GITHUB_MCP_SERVER_E2E_DEBUG** - Set to "true" for in-process e2e debugging
## Key Files Reference
### Root Directory Files
```
.dockerignore - Docker build exclusions
.gitignore - Git exclusions (includes bin/, dist/, vendor/, binaries)
.golangci.yml - Linter configuration
.goreleaser.yaml - Release automation
CODE_OF_CONDUCT.md - Community guidelines
CONTRIBUTING.md - Contribution guide (fork, clone, test, lint workflow)
Dockerfile - Multi-stage Go build
LICENSE - MIT license
README.md - Main documentation (auto-generated sections)
SECURITY.md - Security policy
SUPPORT.md - Support resources
gemini-extension.json - Gemini CLI configuration
go.mod / go.sum - Go dependencies
server.json - MCP server registry metadata
```
### Main Entry Point
`cmd/github-mcp-server/main.go` - Uses cobra for CLI, viper for config, supports:
- `stdio` command (default) - MCP stdio transport
- `generate-docs` command - Documentation generation
- Flags: --toolsets, --read-only, --gh-host, --log-file
## Important Reminders
1. **PRIMARY FOCUS:** The local stdio MCP server (github-mcp-server) - this is what you should work on and test with
2. **REMOTE SERVER:** Ignore remote server instructions when making code changes (unless specifically asked). This repo is used as a library by the remote server, so keep functions exported (capitalized) if they could be called by other repos, even if not needed internally.
3. **ALWAYS** trust these instructions first - only search if information is incomplete or incorrect
4. **NEVER** use `script/tag-release` or push tags
5. **NEVER** skip `script/lint` before committing Go code changes
6. **ALWAYS** update toolsnaps when changing MCP tool schemas
7. **ALWAYS** run `script/generate-docs` after modifying tools
8. For specific test files, use `go test ./path -run TestName` not full suite
9. E2E tests require PAT token - you likely cannot run them
10. Toolsnaps are API documentation - treat changes seriously
11. Build/test/lint are very fast (~1s each) - run frequently
12. CI failures for docs-check or license-check have simple fixes (run the script)
13. mcpcurl is secondary - don't break it, but it's not the priority
+19
View File
@@ -0,0 +1,19 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
version: 2
updates:
- package-ecosystem: "gomod"
directory: "/"
schedule:
interval: "weekly"
- package-ecosystem: "docker"
directory: "/"
schedule:
interval: "weekly"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
+13
View File
@@ -0,0 +1,13 @@
# GitHub MCP Server dependencies
The following open source dependencies are used to build the [github/github-mcp-server][] GitHub Model Context Protocol Server.
## Go Packages
Some packages may only be included on certain architectures or operating systems.
{{ range . }}
- [{{.Name}}](https://pkg.go.dev/{{.Name}}) ([{{.LicenseName}}]({{.LicenseURL}}))
{{- end }}
[github/github-mcp-server]: https://github.com/github/github-mcp-server
@@ -0,0 +1,44 @@
messages:
- role: system
content: |
You are a triage assistant for the GitHub MCP Server repository. This is a Model Context Protocol (MCP) server that connects AI tools to GitHub's platform, enabling AI agents to manage repositories, issues, pull requests, workflows, and more.
Your job is to analyze bug reports and assess their completeness.
**CRITICAL: Detect unfilled templates**
- Flag issues containing unmodified template text like "A clear and concise description of what the bug is"
- Flag placeholder values like "Type this '...'" or "View the output '....'" that haven't been replaced
- Flag generic/meaningless titles (e.g., random words, test content)
- These are ALWAYS "Missing Details" even if the template structure is present
Analyze the issue for these key elements:
1. Clear description of the problem (not template text)
2. Affected version (from running `docker run -i --rm ghcr.io/github/github-mcp-server ./github-mcp-server --version`)
3. Steps to reproduce the behavior (actual steps, not placeholders)
4. Expected vs actual behavior (real descriptions, not template text)
5. Relevant logs (if applicable)
Provide ONE of these assessments:
### AI Assessment: Ready for Review
Use when the bug report has actual information in required fields and can be triaged by a maintainer.
### AI Assessment: Missing Details
Use when:
- Template text has not been replaced with actual content
- Critical information is missing (no reproduction steps, no version info, unclear problem description)
- The title is meaningless or spam-like
- Placeholder text remains in any section
When marking as Missing Details, recommend adding the "waiting-for-reply" label.
### AI Assessment: Unsure
Use when you cannot determine the completeness of the report.
After your assessment header, provide a brief explanation of your rating.
If details are missing, be specific about which sections contain template text or need actual information.
- role: user
content: "{{input}}"
model: openai/gpt-4o-mini
modelParameters:
max_tokens: 500
@@ -0,0 +1,54 @@
messages:
- role: system
content: |
You are a triage assistant for the GitHub MCP Server repository. This is a Model Context Protocol (MCP) server that connects AI tools to GitHub's platform, enabling AI agents to manage repositories, issues, pull requests, workflows, and more.
Your job is to analyze new issues and help categorize them.
**CRITICAL: Detect invalid or incomplete submissions**
- Flag issues with unmodified template text (e.g., "A clear and concise description...")
- Flag placeholder values that haven't been replaced (e.g., "Type this '...'", "....", "XXX")
- Flag meaningless, spam-like, or test titles (e.g., random words, nonsensical content)
- Flag empty or nearly empty issues
- These are ALWAYS "Missing Details" or "Invalid" depending on severity
Analyze the issue to determine:
1. Is this a bug report, feature request, question, documentation issue, or something else?
2. Is the issue clear and well-described with actual content (not template text)?
3. Does it contain enough information for maintainers to act on?
4. Is this potentially spam, a test issue, or completely invalid?
Provide ONE of these assessments:
### AI Assessment: Ready for Review
Use when the issue is clear, well-described with actual content, and contains enough context for maintainers to understand and act on it.
### AI Assessment: Missing Details
Use when:
- Template text has not been replaced with actual content
- The issue is unclear or lacks context
- Critical information is missing to make it actionable
- The title is vague but the issue seems legitimate
When marking as Missing Details, recommend adding the "waiting-for-reply" label.
### AI Assessment: Invalid
Use when:
- The issue appears to be spam or test content
- The title is completely meaningless and body has no useful information
- This doesn't relate to the GitHub MCP Server project at all
When marking as Invalid, recommend adding the "invalid" label and consider closing.
### AI Assessment: Unsure
Use when you cannot determine the nature or completeness of the issue.
After your assessment header, provide a brief explanation including:
- What type of issue this appears to be (bug, feature request, question, invalid, etc.)
- Which specific sections contain template text or need actual information
- What additional information might be helpful if any
- role: user
content: "{{input}}"
model: openai/gpt-4o-mini
modelParameters:
max_tokens: 500
+51
View File
@@ -0,0 +1,51 @@
<!--
Copilot: Fill all sections. Prefer short, concrete answers.
If a checkbox is selected, add a brief explanation.
-->
## Summary
<!-- In 12 sentences: what does this PR do? -->
## Why
<!-- Why is this change needed? Link issues or discussions. -->
Fixes #
## What changed
<!-- Bullet list of concrete changes. -->
-
-
## MCP impact
<!-- Select one or more. If selected, add 12 sentences. -->
- [ ] No tool or API changes
- [ ] Tool schema or behavior changed
- [ ] New tool added
## Prompts tested (tool changes only)
<!-- If you changed or added tools, list example prompts you tested. -->
<!-- Include prompts that trigger the tool and describe the use case. -->
<!-- Example: "List all open issues in the repo assigned to me" -->
-
## Security / limits
<!-- Select if relevant. Add a short note if checked. -->
- [ ] No security or limits impact
- [ ] 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
<!-- Check what you ran. If not run, explain briefly. -->
- [ ] Linted locally with `./script/lint`
- [ ] Tested locally with `./script/test`
## Docs
- [ ] Not needed
- [ ] Updated (README / docs / examples)
+27
View File
@@ -0,0 +1,27 @@
name: AI Issue Assessment
on:
issues:
types: [opened, labeled]
jobs:
ai-issue-assessment:
runs-on: ubuntu-latest
permissions:
issues: write
models: read
contents: read
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Run AI assessment
uses: github/ai-assessment-comment-labeler@e3bedc38cfffa9179fe4cee8f7ecc93bffb3fee7 # v1.0.1
with:
token: ${{ secrets.GITHUB_TOKEN }}
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"
@@ -0,0 +1,28 @@
name: Close inactive issues
on:
schedule:
- cron: "30 8 * * *"
jobs:
close-issues:
runs-on: ubuntu-latest
env:
PR_DAYS_BEFORE_STALE: 30
PR_DAYS_BEFORE_CLOSE: 60
PR_STALE_LABEL: stale
permissions:
issues: write
pull-requests: write
steps:
- uses: actions/stale@v10
with:
days-before-issue-stale: ${{ env.PR_DAYS_BEFORE_STALE }}
days-before-issue-close: ${{ env.PR_DAYS_BEFORE_CLOSE }}
stale-issue-label: ${{ env.PR_STALE_LABEL }}
stale-issue-message: "This issue is stale because it has been open for ${{ env.PR_DAYS_BEFORE_STALE }} days with no activity. Leave a comment to avoid closing this issue in ${{ env.PR_DAYS_BEFORE_CLOSE }} days."
close-issue-message: "This issue was closed because it has been inactive for ${{ env.PR_DAYS_BEFORE_CLOSE }} days since being marked as stale."
days-before-pr-stale: ${{ env.PR_DAYS_BEFORE_STALE }}
days-before-pr-close: ${{ env.PR_DAYS_BEFORE_STALE }}
# Start with the oldest items first
ascending: true
repo-token: ${{ secrets.GITHUB_TOKEN }}
+103
View File
@@ -0,0 +1,103 @@
name: "CodeQL"
run-name: ${{ github.event.inputs.code_scanning_run_name }}
on: [push, pull_request, workflow_dispatch]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
CODE_SCANNING_REF: ${{ github.event.inputs.code_scanning_ref }}
CODE_SCANNING_BASE_BRANCH: ${{ github.event.inputs.code_scanning_base_branch }}
CODE_SCANNING_IS_ANALYZING_DEFAULT_BRANCH: ${{ github.event.inputs.code_scanning_is_analyzing_default_branch }}
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
contents: read
packages: read
security-events: write
continue-on-error: false
strategy:
fail-fast: false
matrix:
include:
- language: actions
category: /language:actions
build-mode: none
runner: '["ubuntu-22.04"]'
- language: go
category: /language:go
build-mode: autobuild
runner: '["ubuntu-22.04"]'
- language: javascript
category: /language:javascript
build-mode: none
runner: '["ubuntu-22.04"]'
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Initialize CodeQL
uses: github/codeql-action/init@v4
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
dependency-caching: ${{ runner.environment == 'github-hosted' }}
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 }} ]
threat-models: [ ]
- name: Setup proxy for registries
id: proxy
uses: github/codeql-action/start-proxy@v4
with:
registries_credentials: ${{ secrets.GITHUB_REGISTRIES_PROXY }}
language: ${{ matrix.language }}
- name: Configure
uses: github/codeql-action/resolve-environment@v4
id: resolve-environment
with:
language: ${{ matrix.language }}
- name: Setup Go
uses: actions/setup-go@v6
if: matrix.language == 'go' && fromJSON(steps.resolve-environment.outputs.environment).configuration.go.version
with:
go-version: ${{ fromJSON(steps.resolve-environment.outputs.environment).configuration.go.version }}
cache: false
- name: Set up Node.js (for JavaScript CodeQL)
if: matrix.language == 'javascript'
uses: actions/setup-node@v6
with:
node-version: "20"
cache: "npm"
cache-dependency-path: ui/package-lock.json
- name: Build UI
if: matrix.language == 'go'
uses: ./.github/actions/build-ui
- name: Autobuild
uses: github/codeql-action/autobuild@v4
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v4
env:
CODEQL_PROXY_HOST: ${{ steps.proxy.outputs.proxy_host }}
CODEQL_PROXY_PORT: ${{ steps.proxy.outputs.proxy_port }}
CODEQL_PROXY_CA_CERTIFICATE: ${{ steps.proxy.outputs.proxy_ca_certificate }}
with:
category: ${{ matrix.category }}
+138
View File
@@ -0,0 +1,138 @@
name: Docker
# This workflow uses actions that are not certified by GitHub.
# They are provided by a third-party and are governed by
# separate terms of service, privacy policy, and support
# documentation.
on:
schedule:
- cron: "27 0 * * *"
push:
branches: ["main", "next"]
# Publish semver tags as releases.
tags: ["v*.*.*"]
pull_request:
branches: ["main", "next"]
workflow_dispatch:
inputs:
description:
required: false
description: "Description of the run."
type: string
default: "Manual run"
env:
# Use docker.io for Docker Hub if empty
REGISTRY: ghcr.io
# github.repository as <account>/<repo>
IMAGE_NAME: ${{ github.repository }}
jobs:
build:
runs-on: ubuntu-latest-xl
permissions:
contents: read
packages: write
# This is used to complete the identity challenge
# with sigstore/fulcio when running outside of PRs.
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v7
# Install the cosign tool except on PR
# https://github.com/sigstore/cosign-installer
- name: Install cosign
if: github.event_name != 'pull_request'
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 #v4.1.2
with:
cosign-release: "v2.2.4"
# Set up BuildKit Docker container builder to be able to build
# multi-platform images and export cache
# https://github.com/docker/setup-buildx-action
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.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@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# Extract metadata (tags, labels) for Docker
# https://github.com/docker/metadata-action
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=schedule
type=ref,event=branch
type=ref,event=tag
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=sha
type=edge
# Custom rule to prevent pre-releases from getting latest tag
type=raw,value=latest,enable=${{ github.ref_type == 'tag' && startsWith(github.ref, 'refs/tags/v') && !contains(github.ref, '-') }}
- name: Go Build Cache for Docker
uses: actions/cache@v5
with:
path: go-build-cache
key: ${{ runner.os }}-go-build-cache-${{ hashFiles('**/go.sum') }}
- name: Inject go-build-cache
uses: reproducible-containers/buildkit-cache-dance@5422eac04292c961a382e0f584ea0f03ad9da723 # v3.4.0
with:
cache-map: |
{
"go-build-cache/apk": "/var/cache/apk",
"go-build-cache/pkg": "/go/pkg/mod",
"go-build-cache/build": "/root/.cache/go-build"
}
# Build and push Docker image with Buildx (don't push on PR)
# https://github.com/docker/build-push-action
- name: Build and push Docker image
id: build-and-push
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
platforms: linux/amd64,linux/arm64
build-args: |
VERSION=${{ github.ref_name }}
secrets: |
oauth_client_id=${{ secrets.OAUTH_CLIENT_ID }}
oauth_client_secret=${{ secrets.OAUTH_CLIENT_SECRET }}
# Sign the resulting Docker image digest except on PRs.
# This will only write to the public Rekor transparency log when the Docker
# repository is public to avoid leaking data. If you would like to publish
# transparency data even for private images, pass --force to cosign below.
# https://github.com/sigstore/cosign
- name: Sign the published Docker image
if: ${{ github.event_name != 'pull_request' }}
env:
# https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-an-intermediate-environment-variable
TAGS: ${{ steps.meta.outputs.tags }}
DIGEST: ${{ steps.build-and-push.outputs.digest }}
# This step uses the identity token to provision an ephemeral certificate
# against the sigstore community Fulcio instance.
run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST}
+50
View File
@@ -0,0 +1,50 @@
name: Documentation Check
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
permissions:
contents: read
jobs:
docs-check:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v7
- name: Build UI
uses: ./.github/actions/build-ui
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version-file: 'go.mod'
- name: Build docs generator
run: go build -o github-mcp-server ./cmd/github-mcp-server
- name: Generate documentation
run: ./github-mcp-server generate-docs
- name: Check for documentation changes
run: |
if ! git diff --exit-code README.md; then
echo "❌ Documentation is out of date!"
echo ""
echo "The generated documentation differs from what's committed."
echo "Please run the following command to update the documentation:"
echo ""
echo " go run ./cmd/github-mcp-server generate-docs"
echo ""
echo "Then commit the changes."
echo ""
echo "Changes detected:"
git diff README.md
exit 1
else
echo "✅ Documentation is up to date!"
fi
+44
View File
@@ -0,0 +1,44 @@
name: Build and Test Go Project
on: [push, pull_request]
permissions:
contents: read
jobs:
build:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
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@v7
- name: Build UI
uses: ./.github/actions/build-ui
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version-file: "go.mod"
- name: Tidy dependencies
run: go mod tidy -diff
- name: Run unit tests
shell: bash
run: script/test
- name: Build
run: go build -v ./cmd/github-mcp-server
+50
View File
@@ -0,0 +1,50 @@
name: GoReleaser Release
on:
push:
tags:
- "v*"
permissions:
contents: write
id-token: write
attestations: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v7
- name: Build UI
uses: ./.github/actions/build-ui
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version-file: "go.mod"
- name: Download dependencies
run: go mod download
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94
with:
distribution: goreleaser
# GoReleaser version
version: "~> v2"
# Arguments to pass to GoReleaser
args: release --clean
workdir: .
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
OAUTH_CLIENT_ID: ${{ secrets.OAUTH_CLIENT_ID }}
OAUTH_CLIENT_SECRET: ${{ secrets.OAUTH_CLIENT_SECRET }}
- name: Generate signed build provenance attestations for workflow artifacts
uses: actions/attest-build-provenance@v4
with:
subject-path: |
dist/*.tar.gz
dist/*.zip
dist/*.txt
+19
View File
@@ -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"
+116
View File
@@ -0,0 +1,116 @@
# 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:
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: write
pull-requests: write
jobs:
license-check:
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v7
# 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: Build UI
uses: ./.github/actions/build-ui
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version-file: "go.mod"
# 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@v9
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@v9
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
+26
View File
@@ -0,0 +1,26 @@
name: golangci-lint
on:
push:
branches:
- main
pull_request:
permissions:
contents: read
jobs:
golangci:
name: lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: Build UI
uses: ./.github/actions/build-ui
- uses: actions/setup-go@v6
with:
go-version: '1.25'
- name: golangci-lint
uses: golangci/golangci-lint-action@v9
with:
# sync with script/lint
version: v2.9
+140
View File
@@ -0,0 +1,140 @@
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@v7
with:
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version-file: go.mod
- name: Build UI
uses: ./.github/actions/build-ui
- name: Stash UI artifacts for baseline checkout
# mcp-server-diff checks the baseline ref out into a separate working
# directory and runs install_command there. Without these prebuilt
# artifacts, pkg/github/ui_dist/ would be empty on the baseline side
# and UIAssetsAvailable() would return false, producing a false-positive
# diff that "adds" _meta.ui to MCP Apps tools on every PR.
run: |
mkdir -p "${RUNNER_TEMP}/ui_dist"
cp pkg/github/ui_dist/*.html "${RUNNER_TEMP}/ui_dist/"
- name: Generate diff configurations
id: configs
# The generator imports pkg/github so any new entry in
# AllowedFeatureFlags is automatically diffed without touching this
# workflow. See script/print-mcp-diff-configs/main.go.
run: |
{
echo 'configurations<<MCP_DIFF_EOF'
go run ./script/print-mcp-diff-configs
echo 'MCP_DIFF_EOF'
} >> "$GITHUB_OUTPUT"
- name: Run MCP Server Diff
# Pinned to the cross-spec-aware mcp-server-diff v3.0.0 (full SHA required —
# Actions rejects shortened SHAs): normalizes _meta plumbing, cache hints,
# initialize envelope, tool-annotation default hints; probes each server at its
# own newest spec via the stateless SEP-2575 server/discover path. Keeps the
# go-sdk v1.6.1 -> v1.7.0-pre.1 bump an honest, signal-only diff. github/copilot-mcp-core#1709.
uses: SamMorrowDrums/mcp-server-diff@40d992e0a220e5b63378758f9a40d6a8982898d2 # v3.0.0
with:
setup_go: "false"
install_command: |
go mod download
mkdir -p pkg/github/ui_dist
cp "${RUNNER_TEMP}"/ui_dist/*.html pkg/github/ui_dist/
start_command: go run ./cmd/github-mcp-server stdio
env_vars: |
GITHUB_PERSONAL_ACCESS_TOKEN=test-token
configurations: ${{ steps.configs.outputs.configurations }}
- 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
mcp-diff-http:
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version-file: go.mod
- name: Build UI
uses: ./.github/actions/build-ui
- name: Stash UI artifacts for baseline checkout
# See the stdio job above for rationale: the action's baseline checkout
# has no UI artifacts unless we hand them over via RUNNER_TEMP.
run: |
mkdir -p "${RUNNER_TEMP}/ui_dist"
cp pkg/github/ui_dist/*.html "${RUNNER_TEMP}/ui_dist/"
- name: Generate diff configurations
id: configs
# See script/print-mcp-diff-configs/main.go. The http-headers variant
# points every config at a shared HTTP server started by the action
# and carries per-config settings via X-MCP-* headers, mirroring how
# the remote server is invoked in production (server-side defaults +
# per-user header overrides).
run: |
{
echo 'configurations<<MCP_DIFF_EOF'
go run ./script/print-mcp-diff-configs -transport http-headers
echo 'MCP_DIFF_EOF'
} >> "$GITHUB_OUTPUT"
- name: Run MCP Server Diff (streamable-http)
# Pinned to mcp-server-diff v3.0.0 — see rationale on the stdio job above.
uses: SamMorrowDrums/mcp-server-diff@40d992e0a220e5b63378758f9a40d6a8982898d2 # v3.0.0
with:
setup_go: "false"
install_command: |
go mod download
mkdir -p pkg/github/ui_dist
cp "${RUNNER_TEMP}"/ui_dist/*.html pkg/github/ui_dist/
http_start_command: go run ./cmd/github-mcp-server http --port 8082
http_startup_wait_ms: "5000"
configurations: ${{ steps.configs.outputs.configurations }}
- name: Add interpretation note
if: always()
run: |
echo "" >> $GITHUB_STEP_SUMMARY
echo "---" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "️ **Note:** This job exercises the streamable-http transport against a shared server, with per-config settings supplied via X-MCP-* request headers." >> $GITHUB_STEP_SUMMARY
+28
View File
@@ -0,0 +1,28 @@
name: AI Moderator
on:
issues:
types: [opened]
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
jobs:
spam-detection:
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
models: read
contents: read
steps:
- uses: actions/checkout@v7
- uses: github/ai-moderator@v1
with:
token: ${{ secrets.GITHUB_TOKEN }}
spam-label: 'spam'
ai-label: 'ai-generated'
minimize-detected-comments: true
enable-spam-detection: true
enable-link-spam-detection: true
enable-ai-detection: true
+83
View File
@@ -0,0 +1,83 @@
name: Publish to MCP Registry
on:
push:
tags: ["v*"] # Triggers on version tags like v1.0.0
workflow_dispatch: # Allow manual triggering
jobs:
publish:
runs-on: ubuntu-latest
permissions:
id-token: write # Required for OIDC authentication
contents: read
steps:
- name: Checkout code
uses: actions/checkout@v7
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version: "stable"
- name: Fetch tags
run: |
if [[ "${{ github.ref_type }}" != "tag" ]]; then
git fetch --tags
else
echo "Skipping tag fetch - already on tag ${{ github.ref_name }}"
fi
- name: Wait for Docker image
run: |
if [[ "${{ github.ref_type }}" == "tag" ]]; then
TAG="${{ github.ref_name }}"
else
TAG=$(git tag --sort=-version:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -n1)
fi
IMAGE="ghcr.io/github/github-mcp-server:$TAG"
for i in {1..10}; do
if docker manifest inspect "$IMAGE" &>/dev/null; then
echo "✅ Docker image ready: $TAG"
break
fi
[ $i -eq 10 ] && { echo "❌ Timeout waiting for $TAG after 5 minutes"; exit 1; }
echo "⏳ Waiting for Docker image ($i/10)..."
sleep 30
done
- name: Install MCP Publisher
run: |
git clone --quiet https://github.com/modelcontextprotocol/registry publisher-repo
cd publisher-repo && make publisher > /dev/null && cd ..
cp publisher-repo/bin/mcp-publisher . && chmod +x mcp-publisher
- name: Update server.json version
run: |
if [[ "${{ github.ref_type }}" == "tag" ]]; then
TAG_VERSION=$(echo "${{ github.ref_name }}" | sed 's/^v//')
else
LATEST_TAG=$(git tag --sort=-version:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -n 1)
[ -z "$LATEST_TAG" ] && { echo "No release tag found"; exit 1; }
TAG_VERSION=$(echo "$LATEST_TAG" | sed 's/^v//')
echo "Using latest tag: $LATEST_TAG"
fi
sed -i "s/\${VERSION}/$TAG_VERSION/g" server.json
echo "Version: $TAG_VERSION"
- name: Validate configuration
run: |
python3 -m json.tool server.json > /dev/null && echo "Configuration valid" || exit 1
- name: Display final server.json
run: |
echo "Final server.json contents:"
cat server.json
- name: Login to MCP Registry (OIDC)
run: ./mcp-publisher login github-oidc
- name: Publish to MCP Registry
run: ./mcp-publisher publish
+34
View File
@@ -0,0 +1,34 @@
.idea
cmd/github-mcp-server/github-mcp-server
# VSCode
.vscode/*
!.vscode/launch.json
# Added by goreleaser init:
dist/
__debug_bin*
# Go
vendor
bin/
# macOS
.DS_Store
# binary
/github-mcp-server
/mcpcurl
/e2e.test
.history
conformance-report/
# UI build artifacts
ui/dist/
ui/node_modules/
# Embedded UI assets (built from ui/)
pkg/github/ui_dist/*
!pkg/github/ui_dist/.gitkeep
!pkg/github/ui_dist/.placeholder.html
+49
View File
@@ -0,0 +1,49 @@
version: "2"
run:
concurrency: 4
tests: true
linters:
enable:
- bodyclose
- gocritic
- gosec
- makezero
- misspell
- modernize
- nakedret
- revive
- errcheck
- staticcheck
- govet
- ineffassign
- intrange
- unused
exclusions:
generated: lax
presets:
- comments
- common-false-positives
- legacy
- std-error-handling
paths:
- third_party$
- builtin$
- examples$
- internal/githubv4mock
rules:
- linters:
- revive
text: "var-naming: avoid package names that conflict with Go standard library package names"
settings:
staticcheck:
checks:
- "all"
- -QF1008
- -ST1000
formatters:
exclusions:
generated: lax
paths:
- third_party$
- builtin$
- examples$
+44
View File
@@ -0,0 +1,44 @@
version: 2
project_name: github-mcp-server
before:
hooks:
- go mod tidy
- go generate ./...
builds:
- env:
- CGO_ENABLED=0
ldflags:
- -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}} -X github.com/github/github-mcp-server/internal/buildinfo.OAuthClientID={{ .Env.OAUTH_CLIENT_ID }} -X github.com/github/github-mcp-server/internal/buildinfo.OAuthClientSecret={{ .Env.OAUTH_CLIENT_SECRET }}
goos:
- linux
- windows
- darwin
main: ./cmd/github-mcp-server
archives:
- formats: tar.gz
# this name template makes the OS and Arch compatible with the results of `uname`.
name_template: >-
{{ .ProjectName }}_
{{- title .Os }}_
{{- if eq .Arch "amd64" }}x86_64
{{- else if eq .Arch "386" }}i386
{{- else }}{{ .Arch }}{{ end }}
{{- if .Arm }}v{{ .Arm }}{{ end }}
# use zip for windows archives
format_overrides:
- goos: windows
formats: zip
changelog:
sort: asc
filters:
exclude:
- "^docs:"
- "^test:"
release:
draft: true
prerelease: auto
name_template: "GitHub MCP Server {{.Version}}"
+38
View File
@@ -0,0 +1,38 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Launch stdio server",
"type": "go",
"request": "launch",
"mode": "auto",
"cwd": "${workspaceFolder}",
"program": "cmd/github-mcp-server/main.go",
"args": ["stdio"],
"console": "integratedTerminal",
},
{
"name": "Launch stdio server (read-only)",
"type": "go",
"request": "launch",
"mode": "auto",
"cwd": "${workspaceFolder}",
"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",
}
]
}
+128
View File
@@ -0,0 +1,128 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity
and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
* Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or
advances of any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email
address, without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official e-mail address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
GitHub.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series
of actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or
permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within
the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.0, available at
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
Community Impact Guidelines were inspired by [Mozilla's code of conduct
enforcement ladder](https://github.com/mozilla/diversity).
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see the FAQ at
https://www.contributor-covenant.org/faq. Translations are available at
https://www.contributor-covenant.org/translations.
+58
View File
@@ -0,0 +1,58 @@
## Contributing
[fork]: https://github.com/github/github-mcp-server/fork
[pr]: https://github.com/github/github-mcp-server/compare
[style]: https://github.com/github/github-mcp-server/blob/main/.golangci.yml
Hi there! We're thrilled that you'd like to contribute to this project. Your help is essential for keeping it great.
Contributions to this project are [released](https://help.github.com/articles/github-terms-of-service/#6-contributions-under-repository-license) to the public under the [project's open source license](LICENSE).
Please note that this project is released with a [Contributor Code of Conduct](CODE_OF_CONDUCT.md). By participating in this project you agree to abide by its terms.
## What we're looking for
We can't guarantee that every tool, feature, or pull request will be approved or merged. Our focus is on supporting high-quality, high-impact capabilities that advance agentic workflows and deliver clear value to developers.
To increase the chances your request is accepted:
* Include real use cases or examples that demonstrate practical value
* Please create an issue outlining the scenario and potential impact, so we can triage it promptly and prioritize accordingly.
* If your request stalls, you can open a Discussion post and link to your issue or PR
* We actively revisit requests that gain strong community engagement (👍s, comments, or evidence of real-world use)
Thanks for contributing and for helping us build toolsets that are truly valuable!
## Prerequisites for running and testing code
These are one time installations required to be able to test your changes locally as part of the pull request (PR) submission process.
1. Install Go [through download](https://go.dev/doc/install) | [through Homebrew](https://formulae.brew.sh/formula/go)
2. [Install golangci-lint v2](https://golangci-lint.run/welcome/install/#local-installation)
## Submitting a pull request
1. [Fork][fork] and clone the repository
2. Make sure the tests pass on your machine: `go test -v ./...`
3. Make sure linter passes on your machine: `golangci-lint run`
4. Create a new branch: `git checkout -b my-branch-name`
5. Add your changes and tests, and make sure the Action workflows still pass
- Run linter: `script/lint`
- Update snapshots and run tests: `UPDATE_TOOLSNAPS=true go test ./...`
- Update readme documentation: `script/generate-docs`
- If renaming a tool, add a deprecation alias (see [Tool Renaming Guide](docs/tool-renaming.md))
- For toolset and icon configuration, see [Toolsets and Icons Guide](docs/toolsets-and-icons.md)
6. Push to your fork and [submit a pull request][pr] targeting the `main` branch
7. Pat yourself on the back and wait for your pull request to be reviewed and merged.
Here are a few things you can do that will increase the likelihood of your pull request being accepted:
- Follow the [style guide][style].
- Write tests.
- Keep your change as focused as possible. If there are multiple changes you would like to make that are not dependent upon each other, consider submitting them as separate pull requests.
- Write a [good commit message](http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html).
## Resources
- [How to Contribute to Open Source](https://opensource.guide/how-to-contribute/)
- [Using Pull Requests](https://help.github.com/articles/about-pull-requests/)
- [GitHub Help](https://help.github.com)
+52
View File
@@ -0,0 +1,52 @@
FROM node:26-alpine@sha256:a2dc166a387cc6ca1e62d0c8e265e49ca985d6e60abc9fe6e6c3d6ce8e63f606 AS ui-build
WORKDIR /app
COPY ui/package*.json ./ui/
RUN cd ui && npm ci
COPY ui/ ./ui/
# Create output directory and build - vite outputs directly to pkg/github/ui_dist/
RUN mkdir -p ./pkg/github/ui_dist && \
cd ui && npm run build
FROM golang:1.25.11-alpine@sha256:523c3effe300580ed375e43f43b1c9b091b68e935a7c3a92bfcc4e7ed55b18c2 AS build
ARG VERSION="dev"
# Set the working directory
WORKDIR /build
# Install git
RUN --mount=type=cache,target=/var/cache/apk \
apk add git
# Copy source code (including ui_dist placeholder)
COPY . .
# Copy built UI assets over the placeholder
COPY --from=ui-build /app/pkg/github/ui_dist/* ./pkg/github/ui_dist/
# Build the server
# OAuth credentials are injected via build secrets so they are not baked into image history; the values are public in practice but kept out of layers.
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
--mount=type=secret,id=oauth_client_id \
--mount=type=secret,id=oauth_client_secret \
export OAUTH_CLIENT_ID="$(cat /run/secrets/oauth_client_id 2>/dev/null || echo '')" && \
export OAUTH_CLIENT_SECRET="$(cat /run/secrets/oauth_client_secret 2>/dev/null || echo '')" && \
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) -X github.com/github/github-mcp-server/internal/buildinfo.OAuthClientID=${OAUTH_CLIENT_ID} -X github.com/github/github-mcp-server/internal/buildinfo.OAuthClientSecret=${OAUTH_CLIENT_SECRET}" \
-o /bin/github-mcp-server ./cmd/github-mcp-server
# Make a stage to run the app
FROM gcr.io/distroless/base-debian12@sha256:e7e678c88c59e70e105a46549bb3fbfb3d732ee3b4afd3a19fdab2e15afaa6b3
# Add required MCP server annotation
LABEL io.modelcontextprotocol.server.name="io.github.github/github-mcp-server"
# Set the working directory
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
CMD ["stdio"]
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 GitHub
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.
+1675
View File
File diff suppressed because it is too large Load Diff
+7
View File
@@ -0,0 +1,7 @@
# WeHub 来源说明
- 原始项目:`github/github-mcp-server`
- 原始仓库:https://github.com/github/github-mcp-server
- 导入方式:上游默认分支的最新快照
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
+31
View File
@@ -0,0 +1,31 @@
Thanks for helping make GitHub safe for everyone.
# Security
GitHub takes the security of our software products and services seriously, including all of the open source code repositories managed through our GitHub organizations, such as [GitHub](https://github.com/GitHub).
Even though [open source repositories are outside of the scope of our bug bounty program](https://bounty.github.com/index.html#scope) and therefore not eligible for bounty rewards, we will ensure that your finding gets passed along to the appropriate maintainers for remediation.
## Reporting Security Issues
If you believe you have found a security vulnerability in any GitHub-owned repository, please report it to us through coordinated disclosure.
**Please do not report security vulnerabilities through public GitHub issues, discussions, or pull requests.**
Instead, please send an email to opensource-security[@]github.com.
Please include as much of the information listed below as you can to help us better understand and resolve the issue:
* The type of issue (e.g., buffer overflow, SQL injection, or cross-site scripting)
* Full paths of source file(s) related to the manifestation of the issue
* The location of the affected source code (tag/branch/commit or direct URL)
* Any special configuration required to reproduce the issue
* Step-by-step instructions to reproduce the issue
* Proof-of-concept or exploit code (if possible)
* Impact of the issue, including how an attacker might exploit the issue
This information will help us triage your report more quickly.
## Policy
See [GitHub's Safe Harbor Policy](https://docs.github.com/en/site-policy/security-policies/github-bug-bounty-program-legal-safe-harbor#1-safe-harbor-terms)
+13
View File
@@ -0,0 +1,13 @@
# Support
## How to file issues and get help
This project uses GitHub issues to track bugs and feature requests. Please search the existing issues before filing new issues to avoid duplicates. For new issues, file your bug or feature request as a new issue.
For help or questions about using this project, please open an issue.
- The `github-mcp-server` is under active development and maintained by GitHub staff **AND THE COMMUNITY**. We will do our best to respond to support, feature requests, and community questions in a timely manner.
## GitHub Support Policy
Support for this project is limited to the resources listed above.
+139
View File
@@ -0,0 +1,139 @@
package main
import (
"context"
"fmt"
"os"
"reflect"
"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"
)
// generateInsidersFeaturesDocs refreshes the auto-generated section of
// docs/insiders-features.md with the tools and schemas affected by each
// Insiders feature flag.
func generateInsidersFeaturesDocs(docsPath string) error {
body := generateFlaggedToolsDoc(github.InsidersFeatureFlags, "_No Insiders-only tool changes._")
return rewriteAutomatedSection(docsPath, "START AUTOMATED INSIDERS TOOLS", "END AUTOMATED INSIDERS TOOLS", body)
}
// generateFeatureFlagsDocs refreshes the auto-generated section of
// docs/feature-flags.md with the tools and schemas affected by each
// user-controllable feature flag.
func generateFeatureFlagsDocs(docsPath string) error {
body := generateFlaggedToolsDoc(github.AllowedFeatureFlags, "_No user-controllable feature flags affect tool registration._")
return rewriteAutomatedSection(docsPath, "START AUTOMATED FEATURE FLAG TOOLS", "END AUTOMATED FEATURE FLAG TOOLS", body)
}
// generateFlaggedToolsDoc renders, for each flag in the input set, the tools
// whose registration or definition differs from the default user experience.
// Each affected tool is printed with its full schema using the same writer
// used by the README so the output style stays consistent.
func generateFlaggedToolsDoc(flags []string, emptyMessage string) string {
t, _ := translations.TranslationHelper()
defaultTools := indexToolsByName(buildInventoryWithFlags(t, nil).ToolsForRegistration(context.Background()))
var buf strings.Builder
hasAny := false
for _, flag := range flags {
affected := flaggedToolDiff(t, flag, defaultTools)
if len(affected) == 0 {
continue
}
if hasAny {
buf.WriteString("\n\n")
}
hasAny = true
fmt.Fprintf(&buf, "### `%s`\n\n", flag)
for i, tool := range affected {
writeToolDoc(&buf, tool)
if i < len(affected)-1 {
buf.WriteString("\n\n")
}
}
}
if !hasAny {
return emptyMessage
}
// Leading/trailing newlines around the body produce blank lines between
// our content and the surrounding marker comments, so the trailing comment
// doesn't get absorbed into the final list item by markdown renderers.
return "\n" + strings.TrimSuffix(buf.String(), "\n") + "\n"
}
// flaggedToolDiff returns the tools whose definition (input schema or meta)
// differs from the default-flagged inventory when only the given flag is on,
// plus tools that exist only in the flag-on inventory. Results are sorted by
// tool name.
func flaggedToolDiff(t translations.TranslationHelperFunc, flag string, defaultTools map[string]inventory.ServerTool) []inventory.ServerTool {
flagTools := buildInventoryWithFlags(t, map[string]bool{flag: true}).ToolsForRegistration(context.Background())
out := make([]inventory.ServerTool, 0)
seen := make(map[string]struct{}, len(flagTools))
for _, tool := range flagTools {
if _, ok := seen[tool.Tool.Name]; ok {
continue
}
seen[tool.Tool.Name] = struct{}{}
baseline, hadBaseline := defaultTools[tool.Tool.Name]
if hadBaseline && reflect.DeepEqual(tool.Tool.InputSchema, baseline.Tool.InputSchema) && reflect.DeepEqual(tool.Tool.Meta, baseline.Tool.Meta) {
continue
}
out = append(out, tool)
}
sort.Slice(out, func(i, j int) bool { return out[i].Tool.Name < out[j].Tool.Name })
return out
}
// buildInventoryWithFlags constructs an inventory whose feature checker treats
// the given flags as enabled and every other flag as disabled. Passing nil
// produces the default-flagged inventory.
func buildInventoryWithFlags(t translations.TranslationHelperFunc, enabled map[string]bool) *inventory.Inventory {
checker := func(_ context.Context, flag string) (bool, error) {
return enabled[flag], nil
}
inv, _ := github.NewInventory(t).
WithToolsets([]string{"all"}).
WithFeatureChecker(checker).
Build()
return inv
}
// indexToolsByName returns a map keyed by tool name. When duplicates exist
// (e.g. flag-gated dual registrations), the first occurrence wins, mirroring
// AvailableTools' deterministic sort order.
func indexToolsByName(tools []inventory.ServerTool) map[string]inventory.ServerTool {
out := make(map[string]inventory.ServerTool, len(tools))
for _, tool := range tools {
if _, ok := out[tool.Tool.Name]; ok {
continue
}
out[tool.Tool.Name] = tool
}
return out
}
// rewriteAutomatedSection reads a markdown file, replaces the content between
// the named markers with body, and writes it back.
func rewriteAutomatedSection(path, startMarker, endMarker, body string) error {
content, err := os.ReadFile(path) //#nosec G304
if err != nil {
return fmt.Errorf("failed to read docs file: %w", err)
}
updated, err := replaceSection(string(content), startMarker, endMarker, body)
if err != nil {
return err
}
return os.WriteFile(path, []byte(updated), 0600) //#nosec G306
}
+509
View File
@@ -0,0 +1,509 @@
package main
import (
"context"
"fmt"
"net/url"
"os"
"slices"
"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/google/jsonschema-go/jsonschema"
"github.com/spf13/cobra"
)
var generateDocsCmd = &cobra.Command{
Use: "generate-docs",
Short: "Generate documentation for tools and toolsets",
Long: `Generate the automated sections of README.md and docs/remote-server.md with current tool and toolset information.`,
RunE: func(_ *cobra.Command, _ []string) error {
return generateAllDocs()
},
}
func init() {
rootCmd.AddCommand(generateDocsCmd)
}
// noFeatureFlagsChecker reports every feature flag as disabled. It models the
// default user experience used by the generated documentation.
func noFeatureFlagsChecker(_ context.Context, _ string) (bool, error) {
return false, nil
}
func generateAllDocs() error {
for _, doc := range []struct {
path string
fn func(string) error
}{
// File to edit, function to generate its docs
{"README.md", generateReadmeDocs},
{"docs/remote-server.md", generateRemoteServerDocs},
{"docs/insiders-features.md", generateInsidersFeaturesDocs},
{"docs/feature-flags.md", generateFeatureFlagsDocs},
{"docs/tool-renaming.md", generateDeprecatedAliasesDocs},
} {
if err := doc.fn(doc.path); err != nil {
return fmt.Errorf("failed to generate docs for %s: %w", doc.path, err)
}
fmt.Printf("Successfully updated %s with automated documentation\n", doc.path)
}
return nil
}
func generateReadmeDocs(readmePath string) error {
// Create translation helper
t, _ := translations.TranslationHelper()
// The README documents the default user experience: tools that are
// enabled with no special flags set. Installing a checker that reports
// every flag as disabled excludes tools gated by FeatureFlagEnable and
// keeps the legacy variants of tools gated by FeatureFlagDisable, so
// flag-gated duplicates don't appear twice.
// Build() can only fail if WithTools specifies invalid tools - not used here
r, _ := github.NewInventory(t).
WithToolsets([]string{"all"}).
WithFeatureChecker(noFeatureFlagsChecker).
Build()
// Generate toolsets documentation
toolsetsDoc := generateToolsetsDoc(r)
// Generate tools documentation
toolsDoc := generateToolsDoc(r)
// Read the current README.md
// #nosec G304 - readmePath is controlled by command line flag, not user input
content, err := os.ReadFile(readmePath)
if err != nil {
return fmt.Errorf("failed to read README.md: %w", err)
}
// Replace toolsets section
updatedContent, err := replaceSection(string(content), "START AUTOMATED TOOLSETS", "END AUTOMATED TOOLSETS", toolsetsDoc)
if err != nil {
return err
}
// Replace tools section
updatedContent, err = replaceSection(updatedContent, "START AUTOMATED TOOLS", "END AUTOMATED TOOLS", toolsDoc)
if err != nil {
return err
}
// Write back to file
err = os.WriteFile(readmePath, []byte(updatedContent), 0600)
if err != nil {
return fmt.Errorf("failed to write README.md: %w", err)
}
return nil
}
func generateRemoteServerDocs(docsPath string) error {
content, err := os.ReadFile(docsPath) //#nosec G304
if err != nil {
return fmt.Errorf("failed to read docs file: %w", err)
}
toolsetsDoc := generateRemoteToolsetsDoc()
// Replace content between markers
updatedContent, err := replaceSection(string(content), "START AUTOMATED TOOLSETS", "END AUTOMATED TOOLSETS", toolsetsDoc)
if err != nil {
return err
}
// Also generate remote-only toolsets section
remoteOnlyDoc := generateRemoteOnlyToolsetsDoc()
updatedContent, err = replaceSection(updatedContent, "START AUTOMATED REMOTE TOOLSETS", "END AUTOMATED REMOTE TOOLSETS", remoteOnlyDoc)
if err != nil {
return err
}
return os.WriteFile(docsPath, []byte(updatedContent), 0600) //#nosec G306
}
// octiconImg returns an img tag for an Octicon that works with GitHub's light/dark theme.
// Uses picture element with prefers-color-scheme for automatic theme switching.
// References icons from the repo's pkg/octicons/icons directory.
// Optional pathPrefix for files in subdirectories (e.g., "../" for docs/).
func octiconImg(name string, pathPrefix ...string) string {
if name == "" {
return ""
}
prefix := ""
if len(pathPrefix) > 0 {
prefix = pathPrefix[0]
}
// Use picture element with media queries for light/dark mode support
// GitHub renders these correctly in markdown
lightIcon := fmt.Sprintf("%spkg/octicons/icons/%s-light.png", prefix, name)
darkIcon := fmt.Sprintf("%spkg/octicons/icons/%s-dark.png", prefix, name)
return fmt.Sprintf(`<picture><source media="(prefers-color-scheme: dark)" srcset="%s"><source media="(prefers-color-scheme: light)" srcset="%s"><img src="%s" width="20" height="20" alt="%s"></picture>`, darkIcon, lightIcon, lightIcon, name)
}
func generateToolsetsDoc(i *inventory.Inventory) string {
var buf strings.Builder
// Add table header and separator (with icon column)
buf.WriteString("| | Toolset | Description |\n")
buf.WriteString("| --- | ----------------------- | ------------------------------------------------------------- |\n")
// Add the context toolset row with custom description (strongly recommended)
// Get context toolset for its icon
contextIcon := octiconImg("person")
fmt.Fprintf(&buf, "| %s | `context` | **Strongly recommended**: Tools that provide context about the current user and GitHub context you are operating in |\n", contextIcon)
// AvailableToolsets() returns toolsets that have tools, sorted by ID
// Exclude context (custom description above)
for _, ts := range i.AvailableToolsets("context") {
icon := octiconImg(ts.Icon)
fmt.Fprintf(&buf, "| %s | `%s` | %s |\n", icon, ts.ID, ts.Description)
}
return strings.TrimSuffix(buf.String(), "\n")
}
func generateToolsDoc(r *inventory.Inventory) string {
tools := r.ToolsForRegistration(context.Background())
if len(tools) == 0 {
return ""
}
var buf strings.Builder
var toolBuf strings.Builder
var currentToolsetID inventory.ToolsetID
var currentToolsetIcon string
firstSection := true
writeSection := func() {
if toolBuf.Len() == 0 {
return
}
if !firstSection {
buf.WriteString("\n\n")
}
firstSection = false
sectionName := formatToolsetName(string(currentToolsetID))
icon := octiconImg(currentToolsetIcon)
if icon != "" {
icon += " "
}
fmt.Fprintf(&buf, "<details>\n\n<summary>%s%s</summary>\n\n%s\n\n</details>", icon, sectionName, strings.TrimSuffix(toolBuf.String(), "\n\n"))
toolBuf.Reset()
}
for _, tool := range tools {
// When toolset changes, emit the previous section
if tool.Toolset.ID != currentToolsetID {
writeSection()
currentToolsetID = tool.Toolset.ID
currentToolsetIcon = tool.Toolset.Icon
}
writeToolDoc(&toolBuf, tool)
toolBuf.WriteString("\n\n")
}
// Emit the last section
writeSection()
return buf.String()
}
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 {
// Scope filtering uses "any of" semantics (see scopes.HasRequiredScopes),
// so when multiple required scopes are listed, render them as alternatives
// rather than implying all are required.
scopeList := "`" + strings.Join(tool.RequiredScopes, "`, `") + "`"
if len(tool.RequiredScopes) > 1 {
fmt.Fprintf(buf, " - **Required OAuth Scopes (any of)**: %s\n", scopeList)
} else {
fmt.Fprintf(buf, " - **Required OAuth Scopes**: %s\n", scopeList)
}
// 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, "`, `"))
}
}
// MCP App UI metadata (only rendered when the remote_mcp_ui_apps flag
// applied to the inventory; for the no-flags README this section is
// stripped by inventory.ToolsForRegistration before rendering).
if ui, ok := tool.Tool.Meta["ui"].(map[string]any); ok {
if uri, ok := ui["resourceUri"].(string); ok && uri != "" {
fmt.Fprintf(buf, " - **MCP App UI**: `%s`\n", uri)
}
}
// Parameters
if tool.Tool.InputSchema == nil {
buf.WriteString(" - No parameters required")
return
}
schema, ok := tool.Tool.InputSchema.(*jsonschema.Schema)
if !ok || schema == nil {
buf.WriteString(" - No parameters required")
return
}
if len(schema.Properties) > 0 {
// Get parameter names and sort them for deterministic order
var paramNames []string
for propName := range schema.Properties {
paramNames = append(paramNames, propName)
}
sort.Strings(paramNames)
for i, propName := range paramNames {
prop := schema.Properties[propName]
required := slices.Contains(schema.Required, propName)
requiredStr := "optional"
if required {
requiredStr = "required"
}
var typeStr string
// Get the type and description
switch prop.Type {
case "array":
if prop.Items != nil {
typeStr = prop.Items.Type + "[]"
} else {
typeStr = "array"
}
default:
typeStr = prop.Type
}
// Indent any continuation lines in the description to maintain markdown formatting
description := indentMultilineDescription(prop.Description, " ")
fmt.Fprintf(buf, " - `%s`: %s (%s, %s)", propName, description, typeStr, requiredStr)
if i < len(paramNames)-1 {
buf.WriteString("\n")
}
}
} else {
buf.WriteString(" - No parameters required")
}
}
// 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
}
// indentMultilineDescription adds the specified indent to all lines after the first line.
// This ensures that multi-line descriptions maintain proper markdown list formatting.
func indentMultilineDescription(description, indent string) string {
if !strings.Contains(description, "\n") {
return description
}
var buf strings.Builder
lines := strings.Split(description, "\n")
buf.WriteString(lines[0])
for i := 1; i < len(lines); i++ {
buf.WriteString("\n")
buf.WriteString(indent)
buf.WriteString(lines[i])
}
return buf.String()
}
func replaceSection(content, startMarker, endMarker, newContent string) (string, error) {
start := fmt.Sprintf("<!-- %s -->", startMarker)
end := fmt.Sprintf("<!-- %s -->", endMarker)
before, _, ok := strings.Cut(content, start)
endIdx := strings.Index(content, end)
if !ok || endIdx == -1 {
return "", fmt.Errorf("markers not found: %s / %s", start, end)
}
var buf strings.Builder
buf.WriteString(before)
buf.WriteString(start)
buf.WriteString("\n")
buf.WriteString(newContent)
buf.WriteString("\n")
buf.WriteString(content[endIdx:])
return buf.String(), nil
}
func generateRemoteToolsetsDoc() string {
var buf strings.Builder
// Create translation helper
t, _ := translations.TranslationHelper()
// Build inventory - stateless
// 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")
buf.WriteString("| ---- | ----------- | ------- | ------------------------- | -------------- | ----------------------------------- |\n")
// Add "default" and "all" meta toolsets first (special cases). The base
// URL serves the default toolset; /x/all enables every toolset at once.
metaIcon := octiconImg("apps", "../")
fmt.Fprintf(&buf, "| %s<br>`default` | Default toolset | https://api.githubcopilot.com/mcp/ | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%%7B%%22type%%22%%3A%%20%%22http%%22%%2C%%22url%%22%%3A%%20%%22https%%3A%%2F%%2Fapi.githubcopilot.com%%2Fmcp%%2F%%22%%7D) | [read-only](https://api.githubcopilot.com/mcp/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%%7B%%22type%%22%%3A%%20%%22http%%22%%2C%%22url%%22%%3A%%20%%22https%%3A%%2F%%2Fapi.githubcopilot.com%%2Fmcp%%2Freadonly%%22%%7D) |\n", metaIcon)
fmt.Fprintf(&buf, "| %s<br>`all` | All available GitHub MCP tools | https://api.githubcopilot.com/mcp/x/all | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-all&config=%%7B%%22type%%22%%3A%%20%%22http%%22%%2C%%22url%%22%%3A%%20%%22https%%3A%%2F%%2Fapi.githubcopilot.com%%2Fmcp%%2Fx%%2Fall%%22%%7D) | [read-only](https://api.githubcopilot.com/mcp/x/all/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-all&config=%%7B%%22type%%22%%3A%%20%%22http%%22%%2C%%22url%%22%%3A%%20%%22https%%3A%%2F%%2Fapi.githubcopilot.com%%2Fmcp%%2Fx%%2Fall%%2Freadonly%%22%%7D) |\n", metaIcon)
// AvailableToolsets() returns toolsets that have tools, sorted by ID
// Exclude context (handled separately)
for _, ts := range r.AvailableToolsets("context") {
idStr := string(ts.ID)
apiURL := fmt.Sprintf("https://api.githubcopilot.com/mcp/x/%s", idStr)
readonlyURL := fmt.Sprintf("https://api.githubcopilot.com/mcp/x/%s/readonly", idStr)
// Create install config JSON (URL encoded)
installConfig := url.QueryEscape(fmt.Sprintf(`{"type": "http","url": "%s"}`, apiURL))
readonlyConfig := url.QueryEscape(fmt.Sprintf(`{"type": "http","url": "%s"}`, readonlyURL))
// Fix URL encoding to use %20 instead of + for spaces
installConfig = strings.ReplaceAll(installConfig, "+", "%20")
readonlyConfig = strings.ReplaceAll(readonlyConfig, "+", "%20")
installLink := fmt.Sprintf("[Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-%s&config=%s)", idStr, installConfig)
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<br>`%s` | %s | %s | %s | [read-only](%s) | %s |\n",
icon,
idStr,
ts.Description,
apiURL,
installLink,
readonlyURL,
readonlyInstallLink,
)
}
return strings.TrimSuffix(buf.String(), "\n")
}
func generateRemoteOnlyToolsetsDoc() string {
var buf strings.Builder
// 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")
buf.WriteString("| ---- | ----------- | ------- | ------------------------- | -------------- | ----------------------------------- |\n")
// Use RemoteOnlyToolsets from github package
for _, ts := range github.RemoteOnlyToolsets() {
idStr := string(ts.ID)
apiURL := fmt.Sprintf("https://api.githubcopilot.com/mcp/x/%s", idStr)
readonlyURL := fmt.Sprintf("https://api.githubcopilot.com/mcp/x/%s/readonly", idStr)
// Create install config JSON (URL encoded)
installConfig := url.QueryEscape(fmt.Sprintf(`{"type": "http","url": "%s"}`, apiURL))
readonlyConfig := url.QueryEscape(fmt.Sprintf(`{"type": "http","url": "%s"}`, readonlyURL))
// Fix URL encoding to use %20 instead of + for spaces
installConfig = strings.ReplaceAll(installConfig, "+", "%20")
readonlyConfig = strings.ReplaceAll(readonlyConfig, "+", "%20")
installLink := fmt.Sprintf("[Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-%s&config=%s)", idStr, installConfig)
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<br>`%s` | %s | %s | %s | [read-only](%s) | %s |\n",
icon,
idStr,
ts.Description,
apiURL,
installLink,
readonlyURL,
readonlyInstallLink,
)
}
return strings.TrimSuffix(buf.String(), "\n")
}
func generateDeprecatedAliasesDocs(docsPath string) error {
// Read the current file
content, err := os.ReadFile(docsPath) //#nosec G304
if err != nil {
return fmt.Errorf("failed to read docs file: %w", err)
}
// Generate the table
aliasesDoc := generateDeprecatedAliasesTable()
// Replace content between markers
updatedContent, err := replaceSection(string(content), "START AUTOMATED ALIASES", "END AUTOMATED ALIASES", aliasesDoc)
if err != nil {
return err
}
// Write back to file
err = os.WriteFile(docsPath, []byte(updatedContent), 0600)
if err != nil {
return fmt.Errorf("failed to write deprecated aliases docs: %w", err)
}
return nil
}
func generateDeprecatedAliasesTable() string {
var buf strings.Builder
// Add table header
buf.WriteString("| Old Name | New Name |\n")
buf.WriteString("|----------|----------|\n")
aliases := github.DeprecatedToolAliases
if len(aliases) == 0 {
buf.WriteString("| *(none currently)* | |")
} else {
// Sort keys for deterministic output
var oldNames []string
for oldName := range aliases {
oldNames = append(oldNames, oldName)
}
sort.Strings(oldNames)
for i, oldName := range oldNames {
newName := aliases[oldName]
fmt.Fprintf(&buf, "| `%s` | `%s` |", oldName, newName)
if i < len(oldNames)-1 {
buf.WriteString("\n")
}
}
}
return buf.String()
}
+29
View File
@@ -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, " ")
}
}
+294
View File
@@ -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
}
+291
View File
@@ -0,0 +1,291 @@
package main
import (
"errors"
"fmt"
"os"
"strings"
"time"
"github.com/github/github-mcp-server/internal/buildinfo"
"github.com/github/github-mcp-server/internal/ghmcp"
"github.com/github/github-mcp-server/internal/oauth"
"github.com/github/github-mcp-server/pkg/github"
ghhttp "github.com/github/github-mcp-server/pkg/http"
ghoauth "github.com/github/github-mcp-server/pkg/http/oauth"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
"github.com/spf13/viper"
)
// These variables are set by the build process using ldflags.
var version = "version"
var commit = "commit"
var date = "date"
var (
rootCmd = &cobra.Command{
Use: "server",
Short: "GitHub MCP Server",
Long: `A GitHub MCP server that handles various tools and resources.`,
Version: fmt.Sprintf("Version: %s\nCommit: %s\nBuild Date: %s", version, commit, date),
}
stdioCmd = &cobra.Command{
Use: "stdio",
Short: "Start stdio server",
Long: `Start a server that communicates via standard input/output streams using JSON-RPC messages.`,
RunE: func(_ *cobra.Command, _ []string) error {
token := viper.GetString("personal_access_token")
oauthClientID := viper.GetString("oauth-client-id")
oauthClientSecret := viper.GetString("oauth-client-secret")
// Fall back to the build-time baked-in client (official releases) when none is
// configured explicitly. The baked-in app is registered on github.com, so it is
// only applied to the default host; GHES/ghe.com users must bring their own
// --oauth-client-id. Recognizing the host via NormalizeHost means an explicit
// GITHUB_HOST=github.com (or api.github.com) still counts as the default and keeps
// zero-config login working. The secret tracks the id, so an explicitly provided
// id with no secret never picks up the baked-in secret.
if oauthClientID == "" && oauth.NormalizeHost(viper.GetString("host")) == "https://github.com" {
oauthClientID = buildinfo.OAuthClientID
oauthClientSecret = buildinfo.OAuthClientSecret
}
if token == "" && oauthClientID == "" {
return errors.New("authentication required: set GITHUB_PERSONAL_ACCESS_TOKEN, or pass --oauth-client-id to log in via OAuth")
}
// If you're wondering why we're not using viper.GetStringSlice("toolsets"),
// it's because viper doesn't handle comma-separated values correctly for env
// vars when using GetStringSlice.
// https://github.com/spf13/viper/issues/380
//
// Additionally, viper.UnmarshalKey returns an empty slice even when the flag
// is not set, but we need nil to indicate "use defaults". So we check IsSet first.
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"
// Parse 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)
}
}
// Parse excluded tools (similar to tools)
var excludeTools []string
if viper.IsSet("exclude_tools") {
if err := viper.UnmarshalKey("exclude_tools", &excludeTools); err != nil {
return fmt.Errorf("failed to unmarshal exclude-tools: %w", err)
}
}
// Parse enabled features (similar to toolsets)
var enabledFeatures []string
if viper.IsSet("features") {
if err := viper.UnmarshalKey("features", &enabledFeatures); err != nil {
return fmt.Errorf("failed to unmarshal features: %w", err)
}
}
ttl := viper.GetDuration("repo-access-cache-ttl")
stdioServerConfig := ghmcp.StdioServerConfig{
Version: version,
Host: viper.GetString("host"),
Token: token,
EnabledToolsets: enabledToolsets,
EnabledTools: enabledTools,
EnabledFeatures: enabledFeatures,
ReadOnly: viper.GetBool("read-only"),
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"),
InsidersMode: viper.GetBool("insiders"),
ExcludeTools: excludeTools,
RepoAccessCacheTTL: &ttl,
}
// When no static token is provided, log in via OAuth using the given
// client. The requested scopes default to the full supported set
// (which filters out no tools); an explicit, narrower --oauth-scopes
// both narrows the grant and hides tools needing other scopes.
if token == "" {
scopes := ghoauth.SupportedScopes
if viper.IsSet("oauth-scopes") {
if err := viper.UnmarshalKey("oauth-scopes", &scopes); err != nil {
return fmt.Errorf("failed to unmarshal oauth-scopes: %w", err)
}
}
oauthConfig := oauth.NewGitHubConfig(
oauthClientID,
oauthClientSecret,
scopes,
viper.GetString("host"),
viper.GetInt("oauth-callback-port"),
)
stdioServerConfig.OAuthManager = oauth.NewManager(oauthConfig, nil)
stdioServerConfig.OAuthScopes = scopes
}
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 {
// Parse toolsets (same approach as stdio — see comment there)
var enabledToolsets []string
if viper.IsSet("toolsets") {
if err := viper.UnmarshalKey("toolsets", &enabledToolsets); err != nil {
return fmt.Errorf("failed to unmarshal toolsets: %w", err)
}
}
var enabledTools []string
if viper.IsSet("tools") {
if err := viper.UnmarshalKey("tools", &enabledTools); err != nil {
return fmt.Errorf("failed to unmarshal tools: %w", err)
}
}
var excludeTools []string
if viper.IsSet("exclude_tools") {
if err := viper.UnmarshalKey("exclude_tools", &excludeTools); err != nil {
return fmt.Errorf("failed to unmarshal exclude-tools: %w", err)
}
}
var enabledFeatures []string
if viper.IsSet("features") {
if err := viper.UnmarshalKey("features", &enabledFeatures); err != nil {
return fmt.Errorf("failed to unmarshal features: %w", err)
}
}
ttl := viper.GetDuration("repo-access-cache-ttl")
httpConfig := ghhttp.ServerConfig{
Version: version,
Host: viper.GetString("host"),
Port: viper.GetInt("port"),
ListenHost: viper.GetString("listen-host"),
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"),
ReadOnly: viper.GetBool("read-only"),
EnabledToolsets: enabledToolsets,
EnabledTools: enabledTools,
ExcludeTools: excludeTools,
EnabledFeatures: enabledFeatures,
InsidersMode: viper.GetBool("insiders"),
TrustProxyHeaders: viper.GetBool("trust-proxy-headers"),
}
return ghhttp.RunHTTPServer(httpConfig)
},
}
)
func init() {
cobra.OnInitialize(initConfig)
rootCmd.SetGlobalNormalizationFunc(wordSepNormalizeFunc)
rootCmd.SetVersionTemplate("{{.Short}}\n{{.Version}}\n")
// Add global flags that will be shared by all commands
rootCmd.PersistentFlags().StringSlice("toolsets", nil, github.GenerateToolsetsHelp())
rootCmd.PersistentFlags().StringSlice("tools", nil, "Comma-separated list of specific tools to enable")
rootCmd.PersistentFlags().StringSlice("exclude-tools", nil, "Comma-separated list of tool names to disable regardless of other settings")
rootCmd.PersistentFlags().StringSlice("features", nil, "Comma-separated list of feature flags to enable")
rootCmd.PersistentFlags().Bool("read-only", false, "Restrict the server to read-only operations")
rootCmd.PersistentFlags().String("log-file", "", "Path to log file")
rootCmd.PersistentFlags().Bool("enable-command-logging", false, "When enabled, the server will log all command requests and responses to the log file")
rootCmd.PersistentFlags().Bool("export-translations", false, "Save translations to a JSON file")
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)")
// stdio-specific OAuth flags. Provide --oauth-client-id (instead of a token)
// to log in via the browser-based OAuth flow on first use. Works for both
// OAuth Apps and GitHub Apps.
stdioCmd.Flags().String("oauth-client-id", "", "OAuth App or GitHub App client ID, enabling interactive OAuth login when no token is set")
stdioCmd.Flags().String("oauth-client-secret", "", "OAuth client secret, if the app requires one (it is a public, non-confidential credential for distributed clients)")
stdioCmd.Flags().StringSlice("oauth-scopes", nil, "Comma-separated OAuth scopes to request; also filters tools to those scopes. Defaults to the full supported set")
stdioCmd.Flags().Int("oauth-callback-port", 0, "Fixed local port for the OAuth callback server. Defaults to a random port; set a fixed port when mapping it through Docker")
// HTTP-specific flags
httpCmd.Flags().Int("port", 8082, "HTTP server port")
httpCmd.Flags().String("listen-host", "", "Host the HTTP server binds to (e.g. 127.0.0.1). Empty binds to all interfaces.")
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")
httpCmd.Flags().Bool("trust-proxy-headers", false, "Honor X-Forwarded-Host and X-Forwarded-Proto when constructing OAuth resource metadata URLs. Only enable when the server is deployed behind a trusted proxy that sets these headers. Ignored when --base-url is set.")
// Bind flag to viper
_ = viper.BindPFlag("toolsets", rootCmd.PersistentFlags().Lookup("toolsets"))
_ = viper.BindPFlag("tools", rootCmd.PersistentFlags().Lookup("tools"))
_ = viper.BindPFlag("exclude_tools", rootCmd.PersistentFlags().Lookup("exclude-tools"))
_ = viper.BindPFlag("features", rootCmd.PersistentFlags().Lookup("features"))
_ = viper.BindPFlag("read-only", rootCmd.PersistentFlags().Lookup("read-only"))
_ = viper.BindPFlag("log-file", rootCmd.PersistentFlags().Lookup("log-file"))
_ = viper.BindPFlag("enable-command-logging", rootCmd.PersistentFlags().Lookup("enable-command-logging"))
_ = viper.BindPFlag("export-translations", rootCmd.PersistentFlags().Lookup("export-translations"))
_ = 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("oauth-client-id", stdioCmd.Flags().Lookup("oauth-client-id"))
_ = viper.BindPFlag("oauth-client-secret", stdioCmd.Flags().Lookup("oauth-client-secret"))
_ = viper.BindPFlag("oauth-scopes", stdioCmd.Flags().Lookup("oauth-scopes"))
_ = viper.BindPFlag("oauth-callback-port", stdioCmd.Flags().Lookup("oauth-callback-port"))
_ = viper.BindPFlag("port", httpCmd.Flags().Lookup("port"))
_ = viper.BindPFlag("listen-host", httpCmd.Flags().Lookup("listen-host"))
_ = 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"))
_ = viper.BindPFlag("trust-proxy-headers", httpCmd.Flags().Lookup("trust-proxy-headers"))
// Add subcommands
rootCmd.AddCommand(stdioCmd)
rootCmd.AddCommand(httpCmd)
}
func initConfig() {
// Initialize Viper configuration
viper.SetEnvPrefix("github")
viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_"))
viper.AutomaticEnv()
}
func main() {
if err := rootCmd.Execute(); err != nil {
fmt.Fprintf(os.Stderr, "%v\n", err)
os.Exit(1)
}
}
func wordSepNormalizeFunc(_ *pflag.FlagSet, name string) pflag.NormalizedName {
from := []string{"_"}
to := "-"
for _, sep := range from {
name = strings.ReplaceAll(name, sep, to)
}
return pflag.NormalizedName(name)
}
+150
View File
@@ -0,0 +1,150 @@
# mcpcurl
A CLI tool that dynamically builds commands based on schemas retrieved from MCP servers that can
be executed against the configured MCP server.
## Overview
`mcpcurl` is a command-line interface that:
1. Connects to an MCP server via stdio
2. Dynamically retrieves the available tools schema
3. Generates CLI commands corresponding to each tool
4. Handles parameter validation based on the schema
5. Executes commands and displays responses
## Installation
### Prerequisites
- Go 1.24 or later
- Access to the GitHub MCP Server from either Docker or local build
### Build from Source
```bash
cd cmd/mcpcurl
go build -o mcpcurl
```
### Using Go Install
```bash
go install github.com/github/github-mcp-server/cmd/mcpcurl@latest
```
### Verify Installation
```bash
./mcpcurl --help
```
## Usage
```console
mcpcurl --stdio-server-cmd="<command to start MCP server>" <command> [flags]
```
The `--stdio-server-cmd` flag is required for all commands and specifies the command to run the MCP server.
### Available Commands
- `tools`: Contains all dynamically generated tool commands from the schema
- `schema`: Fetches and displays the raw schema from the MCP server
- `help`: Shows help for any command
### Examples
List available tools in Github's MCP server:
```console
% ./mcpcurl --stdio-server-cmd "docker run -i --rm -e GITHUB_PERSONAL_ACCESS_TOKEN mcp/github" tools --help
Contains all dynamically generated tool commands from the schema
Usage:
mcpcurl tools [command]
Available Commands:
add_issue_comment Add a comment to an existing issue
create_branch Create a new branch in a GitHub repository
create_issue Create a new issue in a GitHub repository
create_or_update_file Create or update a single file in a GitHub repository
create_pull_request Create a new pull request in a GitHub repository
create_repository Create a new GitHub repository in your account
fork_repository Fork a GitHub repository to your account or specified organization
get_file_contents Get the contents of a file or directory from a GitHub repository
get_issue Get details of a specific issue in a GitHub repository
get_issue_comments Get comments for a GitHub issue
list_commits Get list of commits of a branch in a GitHub repository
list_issues List issues in a GitHub repository with filtering options
push_files Push multiple files to a GitHub repository in a single commit
search_code Search for code across GitHub repositories
search_issues Search for issues and pull requests across GitHub repositories
search_repositories Search for GitHub repositories
search_users Search for users on GitHub
update_issue Update an existing issue in a GitHub repository
Flags:
-h, --help help for tools
Global Flags:
--pretty Pretty print MCP response (only for JSON responses) (default true)
--stdio-server-cmd string Shell command to invoke MCP server via stdio (required)
Use "mcpcurl tools [command] --help" for more information about a command.
```
Get help for a specific tool:
```console
% ./mcpcurl --stdio-server-cmd "docker run -i --rm -e GITHUB_PERSONAL_ACCESS_TOKEN mcp/github" tools get_issue --help
Get details of a specific issue in a GitHub repository
Usage:
mcpcurl tools get_issue [flags]
Flags:
-h, --help help for get_issue
--issue_number float
--owner string
--repo string
Global Flags:
--pretty Pretty print MCP response (only for JSON responses) (default true)
--stdio-server-cmd string Shell command to invoke MCP server via stdio (required)
```
Use one of the tools:
```console
% ./mcpcurl --stdio-server-cmd "docker run -i --rm -e GITHUB_PERSONAL_ACCESS_TOKEN mcp/github" tools get_issue --owner golang --repo go --issue_number 1
{
"active_lock_reason": null,
"assignee": null,
"assignees": [],
"author_association": "CONTRIBUTOR",
"body": "by **rsc+personal@swtch.com**:\n\n\u003cpre\u003eWhat steps will reproduce the problem?\n1. Run build on Ubuntu 9.10, which uses gcc 4.4.1\n\nWhat is the expected output? What do you see instead?\n\nCgo fails with the following error:\n\n{{{\ngo/misc/cgo/stdio$ make\ncgo file.go\ncould not determine kind of name for C.CString\ncould not determine kind of name for C.puts\ncould not determine kind of name for C.fflushstdout\ncould not determine kind of name for C.free\nthrow: sys·mapaccess1: key not in map\n\npanic PC=0x2b01c2b96a08\nthrow+0x33 /media/scratch/workspace/go/src/pkg/runtime/runtime.c:71\n throw(0x4d2daf, 0x0)\nsys·mapaccess1+0x74 \n/media/scratch/workspace/go/src/pkg/runtime/hashmap.c:769\n sys·mapaccess1(0xc2b51930, 0x2b01)\nmain·*Prog·loadDebugInfo+0xa67 \n/media/scratch/workspace/go/src/cmd/cgo/gcc.go:164\n main·*Prog·loadDebugInfo(0xc2bc0000, 0x2b01)\nmain·main+0x352 \n/media/scratch/workspace/go/src/cmd/cgo/main.go:68\n main·main()\nmainstart+0xf \n/media/scratch/workspace/go/src/pkg/runtime/amd64/asm.s:55\n mainstart()\ngoexit /media/scratch/workspace/go/src/pkg/runtime/proc.c:133\n goexit()\nmake: *** [file.cgo1.go] Error 2\n}}}\n\nPlease use labels and text to provide additional information.\u003c/pre\u003e\n",
"closed_at": "2014-12-08T10:02:16Z",
"closed_by": null,
"comments": 12,
"comments_url": "https://api.github.com/repos/golang/go/issues/1/comments",
"created_at": "2009-10-22T06:07:26Z",
"events_url": "https://api.github.com/repos/golang/go/issues/1/events",
[...]
}
```
## Dynamic Commands
All tools provided by the MCP server are automatically available as subcommands under the `tools` command. Each generated command has:
- Appropriate flags matching the tool's input schema
- Validation for required parameters
- Type validation
- Enum validation (for string parameters with allowable values)
- Help text generated from the tool's description
## How It Works
1. `mcpcurl` makes a JSON-RPC request to the server using the `tools/list` method
2. The server responds with a schema describing all available tools
3. `mcpcurl` dynamically builds a command structure based on this schema
4. When a command is executed, arguments are converted to a JSON-RPC request
5. The request is sent to the server via stdin, and the response is printed to stdout
+557
View File
@@ -0,0 +1,557 @@
package main
import (
"bufio"
"crypto/rand"
"encoding/json"
"fmt"
"io"
"math/big"
"os"
"os/exec"
"slices"
"strings"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
type (
// SchemaResponse represents the top-level response containing tools
SchemaResponse struct {
Result Result `json:"result"`
JSONRPC string `json:"jsonrpc"`
ID int `json:"id"`
}
// Result contains the list of available tools
Result struct {
Tools []Tool `json:"tools"`
}
// Tool represents a single command with its schema
Tool struct {
Name string `json:"name"`
Description string `json:"description"`
InputSchema InputSchema `json:"inputSchema"`
}
// InputSchema defines the structure of a tool's input parameters
InputSchema struct {
Type string `json:"type"`
Properties map[string]Property `json:"properties"`
Required []string `json:"required"`
AdditionalProperties bool `json:"additionalProperties"`
Schema string `json:"$schema"`
}
// Property defines a single parameter's type and constraints
Property struct {
Type string `json:"type"`
Description string `json:"description"`
Enum []string `json:"enum,omitempty"`
Minimum *float64 `json:"minimum,omitempty"`
Maximum *float64 `json:"maximum,omitempty"`
Items *PropertyItem `json:"items,omitempty"`
}
// PropertyItem defines the type of items in an array property
PropertyItem struct {
Type string `json:"type"`
Properties map[string]Property `json:"properties,omitempty"`
Required []string `json:"required,omitempty"`
AdditionalProperties bool `json:"additionalProperties,omitempty"`
}
// JSONRPCRequest represents a JSON-RPC 2.0 request
JSONRPCRequest struct {
JSONRPC string `json:"jsonrpc"`
ID int `json:"id"`
Method string `json:"method"`
Params RequestParams `json:"params"`
}
// RequestParams contains the tool name and arguments
RequestParams struct {
Name string `json:"name"`
Arguments map[string]any `json:"arguments"`
}
// Content matches the response format of a text content response
Content struct {
Type string `json:"type"`
Text string `json:"text"`
}
ResponseResult struct {
Content []Content `json:"content"`
}
Response struct {
Result ResponseResult `json:"result"`
JSONRPC string `json:"jsonrpc"`
ID int `json:"id"`
}
)
var (
// Create root command
rootCmd = &cobra.Command{
Use: "mcpcurl",
Short: "CLI tool with dynamically generated commands",
Long: "A CLI tool for interacting with MCP API based on dynamically loaded schemas",
PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
// Skip validation for help and completion commands
if cmd.Name() == "help" || cmd.Name() == "completion" {
return nil
}
// Check if the required global flag is provided
serverCmd, _ := cmd.Flags().GetString("stdio-server-cmd")
if serverCmd == "" {
return fmt.Errorf("--stdio-server-cmd is required")
}
return nil
},
}
// Add schema command
schemaCmd = &cobra.Command{
Use: "schema",
Short: "Fetch schema from MCP server",
Long: "Fetches the tools schema from the MCP server specified by --stdio-server-cmd",
RunE: func(cmd *cobra.Command, _ []string) error {
serverCmd, _ := cmd.Flags().GetString("stdio-server-cmd")
if serverCmd == "" {
return fmt.Errorf("--stdio-server-cmd is required")
}
// Build the JSON-RPC request for tools/list
jsonRequest, err := buildJSONRPCRequest("tools/list", "", nil)
if err != nil {
return fmt.Errorf("failed to build JSON-RPC request: %w", err)
}
// Execute the server command and pass the JSON-RPC request
response, err := executeServerCommand(serverCmd, jsonRequest)
if err != nil {
return fmt.Errorf("error executing server command: %w", err)
}
// Output the response
fmt.Println(response)
return nil
},
}
// Create the tools command
toolsCmd = &cobra.Command{
Use: "tools",
Short: "Access available tools",
Long: "Contains all dynamically generated tool commands from the schema",
}
)
func main() {
rootCmd.AddCommand(schemaCmd)
// Add global flag for stdio server command
rootCmd.PersistentFlags().String("stdio-server-cmd", "", "Shell command to invoke MCP server via stdio (required)")
_ = rootCmd.MarkPersistentFlagRequired("stdio-server-cmd")
// Add global flag for pretty printing
rootCmd.PersistentFlags().Bool("pretty", true, "Pretty print MCP response (only for JSON or JSONL responses)")
// Add the tools command to the root command
rootCmd.AddCommand(toolsCmd)
// Execute the root command once to parse flags
_ = rootCmd.ParseFlags(os.Args[1:])
// Get pretty flag
prettyPrint, err := rootCmd.Flags().GetBool("pretty")
if err != nil {
_, _ = fmt.Fprintf(os.Stderr, "Error getting pretty flag: %v\n", err)
os.Exit(1)
}
// Get server command
serverCmd, err := rootCmd.Flags().GetString("stdio-server-cmd")
if err == nil && serverCmd != "" {
// Fetch schema from server
jsonRequest, err := buildJSONRPCRequest("tools/list", "", nil)
if err == nil {
response, err := executeServerCommand(serverCmd, jsonRequest)
if err == nil {
// Parse the schema response
var schemaResp SchemaResponse
if err := json.Unmarshal([]byte(response), &schemaResp); err == nil {
// Add all the generated commands as subcommands of tools
for _, tool := range schemaResp.Result.Tools {
addCommandFromTool(toolsCmd, &tool, prettyPrint)
}
}
}
}
}
// Execute
if err := rootCmd.Execute(); err != nil {
_, _ = fmt.Fprintf(os.Stderr, "Error executing command: %v\n", err)
os.Exit(1)
}
}
// addCommandFromTool creates a cobra command from a tool schema
func addCommandFromTool(toolsCmd *cobra.Command, tool *Tool, prettyPrint bool) {
// Create command from tool
cmd := &cobra.Command{
Use: tool.Name,
Short: tool.Description,
Run: func(cmd *cobra.Command, _ []string) {
// Build a map of arguments from flags
arguments, err := buildArgumentsMap(cmd, tool)
if err != nil {
_, _ = fmt.Fprintf(os.Stderr, "failed to build arguments map: %v\n", err)
return
}
jsonData, err := buildJSONRPCRequest("tools/call", tool.Name, arguments)
if err != nil {
_, _ = fmt.Fprintf(os.Stderr, "failed to build JSONRPC request: %v\n", err)
return
}
// Execute the server command
serverCmd, err := cmd.Flags().GetString("stdio-server-cmd")
if err != nil {
_, _ = fmt.Fprintf(os.Stderr, "failed to get stdio-server-cmd: %v\n", err)
return
}
response, err := executeServerCommand(serverCmd, jsonData)
if err != nil {
_, _ = fmt.Fprintf(os.Stderr, "error executing server command: %v\n", err)
return
}
if err := printResponse(response, prettyPrint); err != nil {
_, _ = fmt.Fprintf(os.Stderr, "error printing response: %v\n", err)
return
}
},
}
// Initialize viper for this command
viperInit := func() {
viper.Reset()
viper.AutomaticEnv()
viper.SetEnvPrefix(strings.ToUpper(tool.Name))
viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_"))
}
// We'll call the init function directly instead of with cobra.OnInitialize
// to avoid conflicts between commands
viperInit()
// Add flags based on schema properties
for name, prop := range tool.InputSchema.Properties {
isRequired := slices.Contains(tool.InputSchema.Required, name)
// Enhance description to indicate if parameter is optional
description := prop.Description
if !isRequired {
description += " (optional)"
}
switch prop.Type {
case "string":
cmd.Flags().String(name, "", description)
if len(prop.Enum) > 0 {
// Add validation in PreRun for enum values
cmd.PreRunE = func(cmd *cobra.Command, _ []string) error {
for flagName, property := range tool.InputSchema.Properties {
if len(property.Enum) > 0 {
value, _ := cmd.Flags().GetString(flagName)
if value != "" && !slices.Contains(property.Enum, value) {
return fmt.Errorf("%s must be one of: %s", flagName, strings.Join(property.Enum, ", "))
}
}
}
return nil
}
}
case "number":
cmd.Flags().Float64(name, 0, description)
case "integer":
cmd.Flags().Int64(name, 0, description)
case "boolean":
cmd.Flags().Bool(name, false, description)
case "array":
if prop.Items != nil {
switch prop.Items.Type {
case "string":
cmd.Flags().StringSlice(name, []string{}, description)
case "object":
cmd.Flags().String(name+"-json", "", description+" (provide as JSON array)")
}
}
}
if isRequired {
_ = cmd.MarkFlagRequired(name)
}
// Bind flag to viper
_ = viper.BindPFlag(name, cmd.Flags().Lookup(name))
}
// Add command to root
toolsCmd.AddCommand(cmd)
}
// buildArgumentsMap extracts flag values into a map of arguments
func buildArgumentsMap(cmd *cobra.Command, tool *Tool) (map[string]any, error) {
arguments := make(map[string]any)
for name, prop := range tool.InputSchema.Properties {
switch prop.Type {
case "string":
if value, _ := cmd.Flags().GetString(name); value != "" {
arguments[name] = value
}
case "number":
if value, _ := cmd.Flags().GetFloat64(name); value != 0 {
arguments[name] = value
}
case "integer":
if value, _ := cmd.Flags().GetInt64(name); value != 0 {
arguments[name] = value
}
case "boolean":
// For boolean, we need to check if it was explicitly set
if cmd.Flags().Changed(name) {
value, _ := cmd.Flags().GetBool(name)
arguments[name] = value
}
case "array":
if prop.Items != nil {
switch prop.Items.Type {
case "string":
if values, _ := cmd.Flags().GetStringSlice(name); len(values) > 0 {
arguments[name] = values
}
case "object":
if jsonStr, _ := cmd.Flags().GetString(name + "-json"); jsonStr != "" {
var jsonArray []any
if err := json.Unmarshal([]byte(jsonStr), &jsonArray); err != nil {
return nil, fmt.Errorf("error parsing JSON for %s: %w", name, err)
}
arguments[name] = jsonArray
}
}
}
}
}
return arguments, nil
}
// buildJSONRPCRequest creates a JSON-RPC request with the given tool name and arguments
func buildJSONRPCRequest(method, toolName string, arguments map[string]any) (string, error) {
id, err := rand.Int(rand.Reader, big.NewInt(10000))
if err != nil {
return "", fmt.Errorf("failed to generate random ID: %w", err)
}
request := JSONRPCRequest{
JSONRPC: "2.0",
ID: int(id.Int64()), // Random ID between 0 and 9999
Method: method,
Params: RequestParams{
Name: toolName,
Arguments: arguments,
},
}
jsonData, err := json.Marshal(request)
if err != nil {
return "", fmt.Errorf("failed to marshal JSON request: %w", err)
}
return string(jsonData), nil
}
// executeServerCommand runs the specified command, performs the MCP initialization
// handshake, sends the JSON request to stdin, and returns the response from stdout.
func executeServerCommand(cmdStr, jsonRequest string) (string, error) {
// Split the command string into command and arguments
cmdParts := strings.Fields(cmdStr)
if len(cmdParts) == 0 {
return "", fmt.Errorf("empty command")
}
cmd := exec.Command(cmdParts[0], cmdParts[1:]...) //nolint:gosec //mcpcurl is a test command that needs to execute arbitrary shell commands
// Setup stdin pipe
stdin, err := cmd.StdinPipe()
if err != nil {
return "", fmt.Errorf("failed to create stdin pipe: %w", err)
}
// Setup stdout pipe for line-by-line reading
stdoutPipe, err := cmd.StdoutPipe()
if err != nil {
return "", fmt.Errorf("failed to create stdout pipe: %w", err)
}
// Stderr still uses a buffer
var stderr strings.Builder
cmd.Stderr = &stderr
// Start the command
if err := cmd.Start(); err != nil {
return "", fmt.Errorf("failed to start command: %w", err)
}
// Ensure the child process is cleaned up on every return path.
// stdin must be closed before Wait so the server sees EOF and exits;
// its non-zero exit status on EOF is expected, so we ignore the error.
defer func() {
_ = stdin.Close()
_ = cmd.Wait()
}()
// Use a scanner with a large buffer for reading JSON-RPC responses
scanner := bufio.NewScanner(stdoutPipe)
scanner.Buffer(make([]byte, 0, 1024*1024), 1024*1024) // 1MB max line size
// Step 1: Send MCP initialize request
initReq, err := buildInitializeRequest()
if err != nil {
return "", fmt.Errorf("failed to build initialize request: %w", err)
}
if _, err := io.WriteString(stdin, initReq+"\n"); err != nil {
return "", fmt.Errorf("failed to write initialize request: %w", err)
}
// Step 2: Read initialize response (skip any server notifications)
if _, err := readJSONRPCResponse(scanner); err != nil {
return "", fmt.Errorf("failed to read initialize response: %w, stderr: %s", err, stderr.String())
}
// Step 3: Send initialized notification
if _, err := io.WriteString(stdin, buildInitializedNotification()+"\n"); err != nil {
return "", fmt.Errorf("failed to write initialized notification: %w", err)
}
// Step 4: Send the actual request
if _, err := io.WriteString(stdin, jsonRequest+"\n"); err != nil {
return "", fmt.Errorf("failed to write request: %w", err)
}
// Step 5: Read the actual response (skip any server notifications)
response, err := readJSONRPCResponse(scanner)
if err != nil {
return "", fmt.Errorf("failed to read response: %w, stderr: %s", err, stderr.String())
}
return response, nil
}
// buildInitializeRequest creates the MCP initialize handshake request.
func buildInitializeRequest() (string, error) {
id, err := rand.Int(rand.Reader, big.NewInt(10000))
if err != nil {
return "", fmt.Errorf("failed to generate random ID: %w", err)
}
msg := map[string]any{
"jsonrpc": "2.0",
"id": int(id.Int64()),
"method": "initialize",
"params": map[string]any{
"protocolVersion": "2024-11-05",
"capabilities": map[string]any{},
"clientInfo": map[string]any{
"name": "mcpcurl",
"version": "0.1.0",
},
},
}
data, err := json.Marshal(msg)
if err != nil {
return "", fmt.Errorf("failed to marshal initialize request: %w", err)
}
return string(data), nil
}
// buildInitializedNotification creates the MCP initialized notification.
func buildInitializedNotification() string {
return `{"jsonrpc":"2.0","method":"notifications/initialized"}`
}
// readJSONRPCResponse reads lines from the scanner, skipping server-initiated
// notifications (messages without an "id" field), and returns the first response.
func readJSONRPCResponse(scanner *bufio.Scanner) (string, error) {
for scanner.Scan() {
line := scanner.Text()
// JSON-RPC responses have an "id" field; notifications do not.
var msg map[string]json.RawMessage
if err := json.Unmarshal([]byte(line), &msg); err != nil {
return "", fmt.Errorf("failed to parse JSON-RPC message: %w", err)
}
if _, hasID := msg["id"]; hasID {
if errField, hasErr := msg["error"]; hasErr {
return "", fmt.Errorf("server returned error: %s", string(errField))
}
return line, nil
}
// No "id" — this is a notification, skip it
}
if err := scanner.Err(); err != nil {
return "", err
}
return "", fmt.Errorf("unexpected end of output")
}
func printResponse(response string, prettyPrint bool) error {
if !prettyPrint {
fmt.Println(response)
return nil
}
// Parse the JSON response
var resp Response
if err := json.Unmarshal([]byte(response), &resp); err != nil {
return fmt.Errorf("failed to parse JSON: %w", err)
}
// Extract text from content items of type "text"
for _, content := range resp.Result.Content {
if content.Type == "text" {
var textContentObj map[string]any
err := json.Unmarshal([]byte(content.Text), &textContentObj)
if err == nil {
prettyText, err := json.MarshalIndent(textContentObj, "", " ")
if err != nil {
return fmt.Errorf("failed to pretty print text content: %w", err)
}
fmt.Println(string(prettyText))
continue
}
// Fallback parsing as JSONL
var textContentList []map[string]any
if err := json.Unmarshal([]byte(content.Text), &textContentList); err != nil {
return fmt.Errorf("failed to parse text content as a list: %w", err)
}
prettyText, err := json.MarshalIndent(textContentList, "", " ")
if err != nil {
return fmt.Errorf("failed to pretty print array content: %w", err)
}
fmt.Println(string(prettyText))
}
}
// If no text content found, print the original response
if len(resp.Result.Content) == 0 {
fmt.Println(response)
}
return nil
}
+178
View File
@@ -0,0 +1,178 @@
package main
import (
"bufio"
"encoding/json"
"strings"
"testing"
)
func TestReadJSONRPCResponse_DirectResponse(t *testing.T) {
t.Parallel()
input := `{"jsonrpc":"2.0","id":1,"result":{"tools":[]}}` + "\n"
scanner := bufio.NewScanner(strings.NewReader(input))
got, err := readJSONRPCResponse(scanner)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != `{"jsonrpc":"2.0","id":1,"result":{"tools":[]}}` {
t.Fatalf("unexpected response: %s", got)
}
}
func TestReadJSONRPCResponse_SkipsNotifications(t *testing.T) {
t.Parallel()
input := strings.Join([]string{
`{"jsonrpc":"2.0","method":"notifications/resources/list_changed","params":{}}`,
`{"jsonrpc":"2.0","method":"notifications/tools/list_changed"}`,
`{"jsonrpc":"2.0","id":42,"result":{"content":[{"type":"text","text":"hello"}]}}`,
}, "\n") + "\n"
scanner := bufio.NewScanner(strings.NewReader(input))
got, err := readJSONRPCResponse(scanner)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var msg map[string]json.RawMessage
if err := json.Unmarshal([]byte(got), &msg); err != nil {
t.Fatalf("response is not valid JSON: %v", err)
}
// Verify we got the response with id:42, not a notification
var id int
if err := json.Unmarshal(msg["id"], &id); err != nil {
t.Fatalf("failed to parse id: %v", err)
}
if id != 42 {
t.Fatalf("expected id 42, got %d", id)
}
}
func TestReadJSONRPCResponse_NoResponse(t *testing.T) {
t.Parallel()
// Only notifications, no response
input := `{"jsonrpc":"2.0","method":"notifications/resources/list_changed","params":{}}` + "\n"
scanner := bufio.NewScanner(strings.NewReader(input))
_, err := readJSONRPCResponse(scanner)
if err == nil {
t.Fatal("expected error for missing response, got nil")
}
if !strings.Contains(err.Error(), "unexpected end of output") {
t.Fatalf("expected 'unexpected end of output' error, got: %v", err)
}
}
func TestReadJSONRPCResponse_EmptyInput(t *testing.T) {
t.Parallel()
scanner := bufio.NewScanner(strings.NewReader(""))
_, err := readJSONRPCResponse(scanner)
if err == nil {
t.Fatal("expected error for empty input, got nil")
}
}
func TestReadJSONRPCResponse_InvalidJSON(t *testing.T) {
t.Parallel()
input := "not valid json\n"
scanner := bufio.NewScanner(strings.NewReader(input))
_, err := readJSONRPCResponse(scanner)
if err == nil {
t.Fatal("expected error for invalid JSON, got nil")
}
if !strings.Contains(err.Error(), "failed to parse JSON-RPC message") {
t.Fatalf("expected parse error, got: %v", err)
}
}
func TestReadJSONRPCResponse_ServerError(t *testing.T) {
t.Parallel()
input := `{"jsonrpc":"2.0","id":1,"error":{"code":-32601,"message":"method not found"}}` + "\n"
scanner := bufio.NewScanner(strings.NewReader(input))
_, err := readJSONRPCResponse(scanner)
if err == nil {
t.Fatal("expected error for server error response, got nil")
}
if !strings.Contains(err.Error(), "server returned error") {
t.Fatalf("expected 'server returned error', got: %v", err)
}
if !strings.Contains(err.Error(), "method not found") {
t.Fatalf("expected error to contain server message, got: %v", err)
}
}
func TestBuildInitializeRequest(t *testing.T) {
t.Parallel()
got, err := buildInitializeRequest()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var msg map[string]json.RawMessage
if err := json.Unmarshal([]byte(got), &msg); err != nil {
t.Fatalf("result is not valid JSON: %v", err)
}
// Verify required fields
for _, field := range []string{"jsonrpc", "id", "method", "params"} {
if _, ok := msg[field]; !ok {
t.Errorf("missing required field %q", field)
}
}
// Verify method
var method string
if err := json.Unmarshal(msg["method"], &method); err != nil {
t.Fatalf("failed to parse method: %v", err)
}
if method != "initialize" {
t.Errorf("expected method 'initialize', got %q", method)
}
// Verify params contain protocolVersion and clientInfo
var params map[string]json.RawMessage
if err := json.Unmarshal(msg["params"], &params); err != nil {
t.Fatalf("failed to parse params: %v", err)
}
for _, field := range []string{"protocolVersion", "capabilities", "clientInfo"} {
if _, ok := params[field]; !ok {
t.Errorf("missing params field %q", field)
}
}
var version string
if err := json.Unmarshal(params["protocolVersion"], &version); err != nil {
t.Fatalf("failed to parse protocolVersion: %v", err)
}
if version != "2024-11-05" {
t.Errorf("expected protocolVersion '2024-11-05', got %q", version)
}
}
func TestBuildInitializedNotification(t *testing.T) {
t.Parallel()
got := buildInitializedNotification()
var msg map[string]json.RawMessage
if err := json.Unmarshal([]byte(got), &msg); err != nil {
t.Fatalf("result is not valid JSON: %v", err)
}
// Must have jsonrpc and method
var method string
if err := json.Unmarshal(msg["method"], &method); err != nil {
t.Fatalf("failed to parse method: %v", err)
}
if method != "notifications/initialized" {
t.Errorf("expected method 'notifications/initialized', got %q", method)
}
// Must NOT have an id (it's a notification)
if _, hasID := msg["id"]; hasID {
t.Error("notification should not have an 'id' field")
}
}
+125
View File
@@ -0,0 +1,125 @@
# Error Handling
This document describes the error handling patterns used in the GitHub MCP Server, specifically how we handle GitHub API errors and avoid direct use of mcp-go error types.
## Overview
The GitHub MCP Server implements a custom error handling approach that serves two primary purposes:
1. **Tool Response Generation**: Return appropriate MCP tool error responses to clients
2. **Middleware Inspection**: Store detailed error information in the request context for middleware analysis
This dual approach enables better observability and debugging capabilities, particularly for remote server deployments where understanding the nature of failures (rate limiting, authentication, 404s, 500s, etc.) is crucial for validation and monitoring.
## Error Types
### GitHubAPIError
Used for REST API errors from the GitHub API:
```go
type GitHubAPIError struct {
Message string `json:"message"`
Response *github.Response `json:"-"`
Err error `json:"-"`
}
```
### GitHubGraphQLError
Used for GraphQL API errors from the GitHub API:
```go
type GitHubGraphQLError struct {
Message string `json:"message"`
Err error `json:"-"`
}
```
## Usage Patterns
### For GitHub REST API Errors
Instead of directly returning `mcp.NewToolResultError()`, use:
```go
return ghErrors.NewGitHubAPIErrorResponse(ctx, message, response, err), nil
```
This function:
- Creates a `GitHubAPIError` with the provided message, response, and error
- Stores the error in the context for middleware inspection
- Returns an appropriate MCP tool error response
### For GitHub GraphQL API Errors
```go
return ghErrors.NewGitHubGraphQLErrorResponse(ctx, message, err), nil
```
### Context Management
The error handling system uses context to store errors for later inspection:
```go
// Initialize context with error tracking
ctx = errors.ContextWithGitHubErrors(ctx)
// Retrieve errors for inspection (typically in middleware)
apiErrors, err := errors.GetGitHubAPIErrors(ctx)
graphqlErrors, err := errors.GetGitHubGraphQLErrors(ctx)
```
## Design Principles
### User-Actionable vs. Developer Errors
- **User-actionable errors** (authentication failures, rate limits, 404s) should be returned as failed tool calls using the error response functions
- **Developer errors** (JSON marshaling failures, internal logic errors) should be returned as actual Go errors that bubble up through the MCP framework
### Context Limitations
This approach was designed to work around current limitations in mcp-go where context is not propagated through each step of request processing. By storing errors in context values, middleware can inspect them without requiring context propagation.
### Graceful Error Handling
Error storage operations in context are designed to fail gracefully - if context storage fails, the tool will still return an appropriate error response to the client.
## Benefits
1. **Observability**: Middleware can inspect the specific types of GitHub API errors occurring
2. **Debugging**: Detailed error information is preserved without exposing potentially sensitive data in logs
3. **Validation**: Remote servers can use error types and HTTP status codes to validate that changes don't break functionality
4. **Privacy**: Error inspection can be done programmatically using `errors.Is` checks without logging PII
## Example Implementation
```go
func GetIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
return mcp.NewTool("get_issue", /* ... */),
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
owner, err := RequiredParam[string](request, "owner")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
client, err := getClient(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get GitHub client: %w", err)
}
issue, resp, err := client.Issues.Get(ctx, owner, repo, issueNumber)
if err != nil {
return ghErrors.NewGitHubAPIErrorResponse(ctx,
"failed to get issue",
resp,
err,
), nil
}
return MarshalledTextResult(issue), nil
}
}
```
This approach ensures that both the client receives an appropriate error response and any middleware can inspect the underlying GitHub API error for monitoring and debugging purposes.
+431
View File
@@ -0,0 +1,431 @@
# Feature Flags
Feature flags let you opt into experimental tool behavior on top of the default
GitHub MCP Server surface. Insiders Mode turns on a curated subset of these
flags automatically — see [Insiders Features](./insiders-features.md) for that
specific set.
For background on how flags resolve at request time, see the [resolution
section in the Insiders docs](./insiders-features.md#how-feature-flags-are-resolved).
## Enabling a flag
| Method | Remote Server | Local Server |
|--------|---------------|--------------|
| Header | `X-MCP-Features: <flag>,<flag>` | N/A |
| CLI flag | N/A | `--features=<flag>,<flag>` |
| Environment variable | N/A | `GITHUB_FEATURES=<flag>,<flag>` |
Only flags listed in
[`AllowedFeatureFlags`](../pkg/github/feature_flags.go) can be enabled by
end users. Insiders-only flags are not user-toggleable.
---
## Tools affected by each flag
The list below is regenerated from the Go source. For each user-controllable
feature flag, it lists every tool whose **inventory or input schema** differs
from the default — either because the flag introduces a new tool, or because
it selects a flag-aware variant of an existing tool. Flags that only affect
runtime behavior (such as output formatting) won't appear here.
<!-- START AUTOMATED FEATURE FLAG TOOLS -->
### `remote_mcp_ui_apps`
- **create_pull_request** - Open new pull request
- **Required OAuth Scopes**: `repo`
- **MCP App UI**: `ui://github-mcp-server/pr-write`
- `base`: Branch to merge into (string, required)
- `body`: PR description (string, optional)
- `draft`: Create as draft PR (boolean, optional)
- `head`: Branch containing changes (string, required)
- `maintainer_can_modify`: Allow maintainer edits (boolean, optional)
- `owner`: Repository owner (string, required)
- `repo`: Repository name (string, required)
- `reviewers`: GitHub usernames or ORG/team-slug team reviewers to request reviews from (string[], optional)
- `title`: PR title (string, required)
- **get_me** - Get my user profile
- **MCP App UI**: `ui://github-mcp-server/get-me`
- No parameters required
- **issue_write** - Create or update issue/pull request
- **Required OAuth Scopes**: `repo`
- **MCP App UI**: `ui://github-mcp-server/issue-write`
- `assignees`: Usernames to assign to this issue (string[], optional)
- `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)
- `issue_fields`: Issue field values to set or clear. Each item requires 'field_name' and exactly one of 'value', 'field_option_name', or 'delete: true'. (object[], optional)
- `issue_number`: Issue number to update (number, optional)
- `labels`: Labels to apply to this issue (string[], optional)
- `method`: Write operation to perform on a single issue.
Options are:
- 'create' - creates a new issue.
- 'update' - updates an existing issue.
(string, required)
- `milestone`: Milestone number (number, optional)
- `owner`: Repository owner (string, required)
- `repo`: Repository name (string, required)
- `state`: New state (string, optional)
- `state_reason`: Reason for the state change. Ignored unless state is changed. (string, optional)
- `title`: Issue title (string, optional)
- `type`: Type of this issue. Only use if issue types are enabled for this repository. Use list_issue_types tool to get valid type values for this repository or its owner organization. If the repository doesn't support issue types, omit this parameter. (string, optional)
- **ui_get** - Get UI data
- **Required OAuth Scopes (any of)**: `repo`, `read:org`
- **Accepted OAuth Scopes**: `admin:org`, `read:org`, `repo`, `write:org`
- `method`: The type of data to fetch (string, required)
- `owner`: Repository owner (required for all methods) (string, required)
- `repo`: Repository name (required for labels, assignees, milestones, branches, issue fields, reviewers) (string, optional)
- **update_pull_request** - Edit pull request
- **Required OAuth Scopes**: `repo`
- **MCP App UI**: `ui://github-mcp-server/pr-edit`
- `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)
- `maintainer_can_modify`: Allow maintainer edits (boolean, optional)
- `owner`: Repository owner (string, required)
- `pullNumber`: Pull request number to update (number, required)
- `repo`: Repository name (string, required)
- `reviewers`: GitHub usernames or ORG/team-slug team reviewers to request reviews from (string[], optional)
- `state`: New state (string, optional)
- `title`: New title (string, optional)
### `issues_granular`
- **add_issue_comment_reaction** - Add Reaction to Issue or Pull Request Comment
- **Required OAuth Scopes**: `repo`
- `comment_id`: The issue or pull request comment ID (number, required)
- `content`: The emoji reaction type (string, required)
- `owner`: Repository owner (username or organization) (string, required)
- `repo`: Repository name (string, required)
- **add_issue_reaction** - Add Reaction to Issue or Pull Request
- **Required OAuth Scopes**: `repo`
- `content`: The emoji reaction type (string, required)
- `issue_number`: The issue number (number, required)
- `owner`: Repository owner (username or organization) (string, required)
- `repo`: Repository name (string, required)
- **add_sub_issue** - Add Sub-Issue
- **Required OAuth Scopes**: `repo`
- `issue_number`: The parent issue number (number, required)
- `owner`: Repository owner (username or organization) (string, required)
- `replace_parent`: If true, reparent the sub-issue if it already has a parent (boolean, optional)
- `repo`: Repository name (string, required)
- `sub_issue_id`: The ID of the sub-issue to add. ID is not the same as issue number (number, required)
- **create_issue** - Create Issue
- **Required OAuth Scopes**: `repo`
- `body`: Issue body content (optional) (string, optional)
- `owner`: Repository owner (username or organization) (string, required)
- `repo`: Repository name (string, required)
- `title`: Issue title (string, required)
- **remove_sub_issue** - Remove Sub-Issue
- **Required OAuth Scopes**: `repo`
- `issue_number`: The parent issue number (number, required)
- `owner`: Repository owner (username or organization) (string, required)
- `repo`: Repository name (string, required)
- `sub_issue_id`: The ID of the sub-issue to remove. ID is not the same as issue number (number, required)
- **reprioritize_sub_issue** - Reprioritize Sub-Issue
- **Required OAuth Scopes**: `repo`
- `after_id`: The ID of the sub-issue to place this after (either after_id OR before_id should be specified) (number, optional)
- `before_id`: The ID of the sub-issue to place this before (either after_id OR before_id should be specified) (number, optional)
- `issue_number`: The parent issue number (number, required)
- `owner`: Repository owner (username or organization) (string, required)
- `repo`: Repository name (string, required)
- `sub_issue_id`: The ID of the sub-issue to reorder. ID is not the same as issue number (number, required)
- **set_issue_fields** - Set Issue Fields
- **Required OAuth Scopes**: `repo`
- `fields`: Array of issue field values to set. Each element must have a 'field_id' (string, the GraphQL node ID of the field) and exactly one value field: 'text_value' for text fields, 'number_value' for number fields, 'date_value' (ISO 8601 date string) for date fields, or 'single_select_option_id' (the GraphQL node ID of the option) for single select fields. Set 'delete' to true to remove a field value. (object[], required)
- `issue_number`: The issue number to update (number, required)
- `owner`: Repository owner (username or organization) (string, required)
- `repo`: Repository name (string, required)
- **update_issue_assignees** - Update Issue Assignees
- **Required OAuth Scopes**: `repo`
- `assignees`: GitHub usernames to assign to this issue. ([], required)
- `issue_number`: The issue number to update (number, required)
- `owner`: Repository owner (username or organization) (string, required)
- `repo`: Repository name (string, required)
- **update_issue_body** - Update Issue Body
- **Required OAuth Scopes**: `repo`
- `body`: The new body content for the issue (string, required)
- `issue_number`: The issue number to update (number, required)
- `owner`: Repository owner (username or organization) (string, required)
- `repo`: Repository name (string, required)
- **update_issue_labels** - Update Issue Labels
- **Required OAuth Scopes**: `repo`
- `issue_number`: The issue number to update (number, required)
- `labels`: Labels to apply to this issue. ([], required)
- `owner`: Repository owner (username or organization) (string, required)
- `repo`: Repository name (string, required)
- **update_issue_milestone** - Update Issue Milestone
- **Required OAuth Scopes**: `repo`
- `issue_number`: The issue number to update (number, required)
- `milestone`: The milestone number to set on the issue (integer, required)
- `owner`: Repository owner (username or organization) (string, required)
- `repo`: Repository name (string, required)
- **update_issue_state** - Update Issue State
- **Required OAuth Scopes**: `repo`
- `confidence`: How confident you are in this choice. Use 'HIGH' for clear signal or explicit user request, 'MEDIUM' for reasonable inference with some ambiguity, 'LOW' for best guess with limited signal. (string, optional)
- `duplicate_of`: The issue number of the canonical issue this issue duplicates. Only valid when state_reason is 'duplicate'. Required when is_suggestion is true and state_reason is 'duplicate'. The issue number is resolved to a database ID before being sent to the API. (number, optional)
- `is_suggestion`: If true, this state change is sent to the API as a suggestion (suggest:true) rather than an applied change. Whether the change is applied or recorded as a proposal is determined by the API. (boolean, optional)
- `issue_number`: The issue number to update (number, required)
- `owner`: Repository owner (username or organization) (string, required)
- `rationale`: One concise sentence explaining what specifically about the issue led you to choose this state. State the concrete signal (e.g. 'The reported crash is fixed in v2.1' → completed). (string, optional)
- `repo`: Repository name (string, required)
- `state`: The new state for the issue (string, required)
- `state_reason`: The reason for the state change (only for closed state) (string, optional)
- **update_issue_title** - Update Issue Title
- **Required OAuth Scopes**: `repo`
- `issue_number`: The issue number to update (number, required)
- `owner`: Repository owner (username or organization) (string, required)
- `repo`: Repository name (string, required)
- `title`: The new title for the issue (string, required)
- **update_issue_type** - Update Issue Type
- **Required OAuth Scopes**: `repo`
- `confidence`: How confident you are in this choice. Use 'HIGH' for clear signal or explicit user request, 'MEDIUM' for reasonable inference with some ambiguity, 'LOW' for best guess with limited signal. (string, optional)
- `is_suggestion`: If true, this issue type change is sent to the API as a suggestion (suggest:true) rather than an applied value. Whether the type is applied or recorded as a proposal is determined by the API. (boolean, optional)
- `issue_number`: The issue number to update (number, required)
- `issue_type`: The issue type to set (string, required)
- `owner`: Repository owner (username or organization) (string, required)
- `rationale`: One concise sentence explaining what specifically about the issue led you to choose this type. State the concrete signal (e.g. 'Reports a crash when saving' → bug, 'Asks for dark mode support' → feature). (string, optional)
- `repo`: Repository name (string, required)
### `pull_requests_granular`
- **add_pull_request_review_comment** - Add Pull Request Review Comment
- **Required OAuth Scopes**: `repo`
- `body`: The comment body (string, required)
- `line`: The line number in the diff to comment on (optional) (number, optional)
- `owner`: Repository owner (username or organization) (string, required)
- `path`: The relative path of the file to comment on (string, required)
- `pullNumber`: The pull request number (number, required)
- `repo`: Repository name (string, required)
- `side`: The side of the diff to comment on (optional) (string, optional)
- `startLine`: The start line of a multi-line comment (optional) (number, optional)
- `startSide`: The start side of a multi-line comment (optional) (string, optional)
- `subjectType`: The subject type of the comment (string, required)
- **add_pull_request_review_comment_reaction** - Add Pull Request Review Comment Reaction
- **Required OAuth Scopes**: `repo`
- `comment_id`: The numeric pull request review comment ID. Use the number from a #discussion_r... anchor, not the GraphQL thread node ID (PRRT_...). (number, required)
- `content`: The emoji reaction type (string, required)
- `owner`: Repository owner (username or organization) (string, required)
- `repo`: Repository name (string, required)
- **create_pull_request_review** - Create Pull Request Review
- **Required OAuth Scopes**: `repo`
- `body`: The review body text (optional) (string, optional)
- `commitID`: The SHA of the commit to review (optional, defaults to latest) (string, optional)
- `event`: The review action to perform. If omitted, creates a pending review. (string, optional)
- `owner`: Repository owner (username or organization) (string, required)
- `pullNumber`: The pull request number (number, required)
- `repo`: Repository name (string, required)
- **delete_pending_pull_request_review** - Delete Pending Pull Request Review
- **Required OAuth Scopes**: `repo`
- `owner`: Repository owner (username or organization) (string, required)
- `pullNumber`: The pull request number (number, required)
- `repo`: Repository name (string, required)
- **request_pull_request_reviewers** - Request Pull Request Reviewers
- **Required OAuth Scopes**: `repo`
- `owner`: Repository owner (username or organization) (string, required)
- `pullNumber`: The pull request number (number, required)
- `repo`: Repository name (string, required)
- `reviewers`: GitHub usernames or ORG/team-slug team reviewers to request reviews from (string[], required)
- **resolve_review_thread** - Resolve Review Thread
- **Required OAuth Scopes**: `repo`
- `threadID`: The node ID of the review thread to resolve (e.g., PRRT_kwDOxxx) (string, required)
- **submit_pending_pull_request_review** - Submit Pending Pull Request Review
- **Required OAuth Scopes**: `repo`
- `body`: The review body text (optional) (string, optional)
- `event`: The review action to perform (string, required)
- `owner`: Repository owner (username or organization) (string, required)
- `pullNumber`: The pull request number (number, required)
- `repo`: Repository name (string, required)
- **unresolve_review_thread** - Unresolve Review Thread
- **Required OAuth Scopes**: `repo`
- `threadID`: The node ID of the review thread to unresolve (e.g., PRRT_kwDOxxx) (string, required)
- **update_pull_request_body** - Update Pull Request Body
- **Required OAuth Scopes**: `repo`
- `body`: The new body content for the pull request (string, required)
- `owner`: Repository owner (username or organization) (string, required)
- `pullNumber`: The pull request number (number, required)
- `repo`: Repository name (string, required)
- **update_pull_request_draft_state** - Update Pull Request Draft State
- **Required OAuth Scopes**: `repo`
- `draft`: Set to true to convert to draft, false to mark as ready for review (boolean, required)
- `owner`: Repository owner (username or organization) (string, required)
- `pullNumber`: The pull request number (number, required)
- `repo`: Repository name (string, required)
- **update_pull_request_state** - Update Pull Request State
- **Required OAuth Scopes**: `repo`
- `owner`: Repository owner (username or organization) (string, required)
- `pullNumber`: The pull request number (number, required)
- `repo`: Repository name (string, required)
- `state`: The new state for the pull request (string, required)
- **update_pull_request_title** - Update Pull Request Title
- **Required OAuth Scopes**: `repo`
- `owner`: Repository owner (username or organization) (string, required)
- `pullNumber`: The pull request number (number, required)
- `repo`: Repository name (string, required)
- `title`: The new title for the pull request (string, required)
### `file_blame`
- **get_file_blame** - Get file blame information
- **Required OAuth Scopes**: `repo`
- `after`: Cursor for pagination. Use the cursor from the previous response. (string, optional)
- `end_line`: Optional 1-based ending line of the window of interest. Must be >= start_line when both are provided. (number, optional)
- `owner`: Repository owner (username or organization) (string, required)
- `path`: Path to the file in the repository, relative to the repository root (string, required)
- `perPage`: Results per page for pagination (min 1, max 100) (number, optional)
- `ref`: Git reference (branch, tag, or commit SHA). Defaults to the repository's default branch (HEAD). (string, optional)
- `repo`: Repository name (string, required)
- `start_line`: Optional 1-based starting line of the window of interest. Only ranges overlapping [start_line, end_line] are returned, clamped to the window. (number, optional)
### `issue_dependencies`
- **issue_dependency_read** - Read issue dependencies
- **Required OAuth Scopes**: `repo`
- `issue_number`: The number of the issue (number, required)
- `method`: The read operation to perform on a single issue's dependencies.
Options are:
1. get_blocked_by - List the issues that block this issue (this issue is blocked by them).
2. get_blocking - List the issues that this issue blocks.
(string, required)
- `owner`: The owner of the repository (string, required)
- `page`: Page number for pagination (min 1) (number, optional)
- `perPage`: Results per page for pagination (min 1, max 100) (number, optional)
- `repo`: The name of the repository (string, required)
- **issue_dependency_write** - Change issue dependency
- **Required OAuth Scopes**: `repo`
- `issue_number`: The number of the subject issue (number, required)
- `method`: The action to perform.
Options are:
- 'add' - create the dependency relationship.
- 'remove' - delete the dependency relationship. (string, required)
- `owner`: The owner of the subject issue's repository (string, required)
- `related_issue_number`: The number of the related issue to link or unlink (number, required)
- `related_owner`: The owner of the related issue's repository. Defaults to 'owner' when omitted. (string, optional)
- `related_repo`: The name of the related issue's repository. Defaults to 'repo' when omitted. (string, optional)
- `repo`: The name of the subject issue's repository (string, required)
- `type`: The relationship direction relative to the subject issue.
Options are:
- 'blocked_by' - the subject issue is blocked by the related issue.
- 'blocking' - the subject issue blocks the related issue. (string, required)
### `fields_param`
- **get_file_contents** - Get file or directory contents
- **Required OAuth Scopes**: `repo`
- `fields`: Subset of fields to return for each entry when the path is a directory. If omitted, all fields are returned. Ignored when the path is a single file. Use this to reduce response size when listing directories and you only need specific fields, e.g. just 'name' and 'type'. (string[], optional)
- `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)
- `repo`: Repository name (string, required)
- `sha`: Accepts optional commit SHA. If specified, it will be used instead of ref (string, optional)
- **list_commits** - List commits
- **Required OAuth Scopes**: `repo`
- `author`: Author username or email address to filter commits by (string, optional)
- `fields`: Subset of fields to return for each commit. If omitted, all fields are returned. Use this to reduce response size when you only need specific fields, e.g. just 'sha' and 'html_url'. (string[], optional)
- `owner`: Repository owner (string, required)
- `page`: Page number for pagination (min 1) (number, optional)
- `path`: Only commits containing this file path will be returned (string, optional)
- `perPage`: Results per page for pagination (min 1, max 100) (number, optional)
- `repo`: Repository name (string, required)
- `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)
- `since`: Only commits after this date will be returned (ISO 8601 format: YYYY-MM-DDTHH:MM:SSZ or YYYY-MM-DD) (string, optional)
- `until`: Only commits before this date will be returned (ISO 8601 format: YYYY-MM-DDTHH:MM:SSZ or YYYY-MM-DD) (string, optional)
- **list_issues** - List issues
- **Required OAuth Scopes**: `repo`
- `after`: Cursor for pagination. Use the cursor from the previous response. (string, optional)
- `direction`: Order direction. If provided, the 'orderBy' also needs to be provided. (string, optional)
- `field_filters`: Filter by custom issue field values. Each entry takes a field_name and a value; the server looks up the field and coerces the value to its type (single-select option name, text, number, or YYYY-MM-DD date). (object[], optional)
- `fields`: Subset of fields to return for each issue. If omitted, all fields are returned. Use this to reduce response size when you only need specific fields; omitting 'body' and 'field_values' in particular drops the largest per-result data. (string[], optional)
- `labels`: Filter by labels (string[], optional)
- `orderBy`: Order issues by field. If provided, the 'direction' also needs to be provided. (string, optional)
- `owner`: Repository owner (string, required)
- `perPage`: Results per page for pagination (min 1, max 100) (number, optional)
- `repo`: Repository name (string, required)
- `since`: Filter by date (ISO 8601 timestamp) (string, optional)
- `state`: Filter by state, by default both open and closed issues are returned when not provided (string, optional)
- **list_pull_requests** - List pull requests
- **Required OAuth Scopes**: `repo`
- `base`: Filter by base branch (string, optional)
- `direction`: Sort direction (string, optional)
- `fields`: Subset of fields to return for each pull request. If omitted, all fields are returned. Use this to reduce response size when you only need specific fields; omitting 'body' in particular drops the largest per-result data. (string[], optional)
- `head`: Filter by head user/org and branch (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)
- `sort`: Sort by (string, optional)
- `state`: Filter by state (string, optional)
- **list_releases** - List releases
- **Required OAuth Scopes**: `repo`
- `fields`: Subset of fields to return for each release. If omitted, all fields are returned. Use this to reduce response size when you only need specific fields; omitting 'body' in particular drops the largest per-release data. (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)
- **search_code** - Search code
- **Required OAuth Scopes**: `repo`
- `fields`: Subset of fields to return for each code search result. If omitted, all fields are returned. Use this to reduce response size when you only need specific fields; omitting 'repository' and 'text_matches' in particular drops the largest per-result data. (string[], optional)
- `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)
- `query`: Search query (GitHub code search REST). Implicit AND between terms; supports `OR`, `NOT`, and `"quoted phrase"` for exact match. Qualifiers: `repo:owner/repo`, `org:`, `user:`, `language:`, `path:dir` (prefix match), `filename:exact.ext`, `extension:`, `in:file`, `in:path`, `size:`, `is:archived`, `is:fork`. Max 256 chars. Examples: `WithContext language:go org:github`; `"package main" repo:o/r`; `func extension:go path:cmd repo:o/r`; `NOT TODO language:go repo:o/r`. (string, required)
- `sort`: Sort field ('indexed' only) (string, optional)
- **search_issues** - Search issues
- **Required OAuth Scopes**: `repo`
- `fields`: Subset of fields to return for each issue result. If omitted, all fields are returned. Use this to reduce response size when you only need specific fields; omitting 'body', 'reactions', and 'labels' in particular drops the largest per-result data. (string[], optional)
- `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)
- `perPage`: Results per page for pagination (min 1, max 100) (number, optional)
- `query`: Search query using GitHub issues search syntax (string, required)
- `repo`: Optional repository name. If provided with owner, only issues for this repository are listed. (string, optional)
- `sort`: Sort field by number of matches of categories, defaults to best match (string, optional)
- **search_pull_requests** - Search pull requests
- **Required OAuth Scopes**: `repo`
- `fields`: Subset of fields to return for each pull request result. If omitted, all fields are returned. Use this to reduce response size when you only need specific fields; omitting 'body', 'reactions', and 'labels' in particular drops the largest per-result data. (string[], optional)
- `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)
- `perPage`: Results per page for pagination (min 1, max 100) (number, optional)
- `query`: Search query using GitHub pull request search syntax (string, required)
- `repo`: Optional repository name. If provided with owner, only pull requests for this repository are listed. (string, optional)
- `sort`: Sort field by number of matches of categories, defaults to best match (string, optional)
<!-- END AUTOMATED FEATURE FLAG TOOLS -->
+193
View File
@@ -0,0 +1,193 @@
# GitHub Remote MCP Integration Guide for MCP Host Authors
This guide outlines high-level considerations for MCP Host authors who want to allow installation of the Remote GitHub MCP server.
The goal is to explain the architecture at a high-level, define key requirements, and provide guidance to get you started, while pointing to official documentation for deeper implementation details.
---
## Table of Contents
- [Understanding MCP Architecture](#understanding-mcp-architecture)
- [Connecting to the Remote GitHub MCP Server](#connecting-to-the-remote-github-mcp-server)
- [Authentication and Authorization](#authentication-and-authorization)
- [OAuth Support on GitHub](#oauth-support-on-github)
- [Create an OAuth-enabled App Using the GitHub UI](#create-an-oauth-enabled-app-using-the-github-ui)
- [Things to Consider](#things-to-consider)
- [Initiating the OAuth Flow from your Client Application](#initiating-the-oauth-flow-from-your-client-application)
- [Handling Organization Access Restrictions](#handling-organization-access-restrictions)
- [Essential Security Considerations](#essential-security-considerations)
- [Additional Resources](#additional-resources)
---
## Understanding MCP Architecture
The Model Context Protocol (MCP) enables seamless communication between your application and various external tools through an architecture defined by the [MCP Standard](https://modelcontextprotocol.io/).
### High-level Architecture
The diagram below illustrates how a single client application can connect to multiple MCP Servers, each providing access to a unique set of resources. Notice that some MCP Servers are running locally (side-by-side with the client application) while others are hosted remotely. GitHub's MCP offerings are available to run either locally or remotely.
```mermaid
flowchart LR
subgraph "Local Runtime Environment"
subgraph "Client Application (e.g., IDE)"
CLIENTAPP[Application Runtime]
CX["MCP Client (FileSystem)"]
CY["MCP Client (GitHub)"]
CZ["MCP Client (Other)"]
end
LOCALMCP[File System MCP Server]
end
subgraph "Internet"
GITHUBMCP[GitHub Remote MCP Server]
OTHERMCP[Other Remote MCP Server]
end
CLIENTAPP --> CX
CLIENTAPP --> CY
CLIENTAPP --> CZ
CX <-->|"stdio"| LOCALMCP
CY <-->|"OAuth 2.0 + HTTP/SSE"| GITHUBMCP
CZ <-->|"OAuth 2.0 + HTTP/SSE"| OTHERMCP
```
### Runtime Environment
- **Application**: The user-facing application you are building. It instantiates one or more MCP clients and orchestrates tool calls.
- **MCP Client**: A component within your client application that maintains a 1:1 connection with a single MCP server.
- **MCP Server**: A service that provides access to a specific set of tools.
- **Local MCP Server**: An MCP Server running locally, side-by-side with the Application.
- **Remote MCP Server**: An MCP Server running remotely, accessed via the internet. Most Remote MCP Servers require authentication via OAuth.
For more detail, see the [official MCP specification](https://modelcontextprotocol.io/specification/2025-06-18).
> [!NOTE]
> GitHub offers both a Local MCP Server and a Remote MCP Server.
---
## Connecting to the Remote GitHub MCP Server
### Authentication and Authorization
GitHub MCP Servers require a valid access token in the `Authorization` header. This is true for both the Local GitHub MCP Server and the Remote GitHub MCP Server.
For the Remote GitHub MCP Server, the recommended way to obtain a valid access token is to ensure your client application supports [OAuth 2.1](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-13). It should be noted, however, that you may also supply any valid access token. For example, you may supply a pre-generated Personal Access Token (PAT).
> [!IMPORTANT]
> The Remote GitHub MCP Server itself does not provide Authentication services.
> Your client application must obtain valid GitHub access tokens through one of the supported methods.
The expected flow for obtaining a valid access token via OAuth is depicted in the [MCP Specification](https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization#authorization-flow-steps). For convenience, we've embedded a copy of the authorization flow below. Please study it carefully as the remainder of this document is written with this flow in mind.
```mermaid
sequenceDiagram
participant B as User-Agent (Browser)
participant C as Client
participant M as MCP Server (Resource Server)
participant A as Authorization Server
C->>M: MCP request without token
M->>C: HTTP 401 Unauthorized with WWW-Authenticate header
Note over C: Extract resource_metadata URL from WWW-Authenticate
C->>M: Request Protected Resource Metadata
M->>C: Return metadata
Note over C: Parse metadata and extract authorization server(s)<br/>Client determines AS to use
C->>A: GET /.well-known/oauth-authorization-server
A->>C: Authorization server metadata response
alt Dynamic client registration
C->>A: POST /register
A->>C: Client Credentials
end
Note over C: Generate PKCE parameters
C->>B: Open browser with authorization URL + code_challenge
B->>A: Authorization request
Note over A: User authorizes
A->>B: Redirect to callback with authorization code
B->>C: Authorization code callback
C->>A: Token request + code_verifier
A->>C: Access token (+ refresh token)
C->>M: MCP request with access token
M-->>C: MCP response
Note over C,M: MCP communication continues with valid token
```
> [!NOTE]
> Dynamic Client Registration is NOT supported by Remote GitHub MCP Server at this time.
#### OAuth Support on GitHub
GitHub offers two solutions for obtaining access tokens via OAuth: [**GitHub Apps**](https://docs.github.com/en/apps/using-github-apps/about-using-github-apps#about-github-apps) and [**OAuth Apps**](https://docs.github.com/en/apps/oauth-apps). These solutions are typically created, administered, and maintained by GitHub Organization administrators. Collaborate with a GitHub Organization administrator to configure either a **GitHub App** or an **OAuth App** to allow your client application to utilize GitHub OAuth support. Furthermore, be aware that it may be necessary for users of your client application to register your **GitHub App** or **OAuth App** within their own GitHub Organization in order to generate authorization tokens capable of accessing Organization's GitHub resources.
> [!TIP]
> Before proceeding, check whether your organization already supports one of these solutions. Administrators of your GitHub Organization can help you determine what **GitHub Apps** or **OAuth Apps** are already registered. If there's an existing **GitHub App** or **OAuth App** that fits your use case, consider reusing it for Remote MCP Authorization. That said, be sure to take heed of the following warning.
> [!WARNING]
> Both **GitHub Apps** and **OAuth Apps** require the client application to pass a "client secret" in order to initiate the OAuth flow. If your client application is designed to run in an uncontrolled environment (i.e. customer-provided hardware), end users will be able to discover your "client secret" and potentially exploit it for other purposes. In such cases, our recommendation is to register a new **GitHub App** (or **OAuth App**) exclusively dedicated to servicing OAuth requests from your client application.
#### Create an OAuth-enabled App Using the GitHub UI
Detailed instructions for creating a **GitHub App** can be found at ["Creating GitHub Apps"](https://docs.github.com/en/apps/creating-github-apps/about-creating-github-apps/about-creating-github-apps#building-a-github-app). (RECOMMENDED)<br/>
Detailed instructions for creating an **OAuth App** can be found ["Creating an OAuth App"](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app).
For guidance on which type of app to choose, see ["Differences Between GitHub Apps and OAuth Apps"](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/differences-between-github-apps-and-oauth-apps).
#### Things to Consider:
- Tokens provided by **GitHub Apps** are generally more secure because they:
- include an expiration
- include support for fine-grained permissions
- **GitHub Apps** must be installed on a GitHub Organization before they can be used.<br/>In general, installation must be approved by someone in the Organization with administrator permissions. For more details, see [this explanation](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/differences-between-github-apps-and-oauth-apps#who-can-install-github-apps-and-authorize-oauth-apps).<br/>By contrast, **OAuth Apps** don't require installation and, typically, can be used immediately.
- Members of an Organization may use the GitHub UI to [request that a GitHub App be installed](https://docs.github.com/en/apps/using-github-apps/requesting-a-github-app-from-your-organization-owner) organization-wide.
- While not strictly necessary, if you expect that a wide range of users will use your MCP Server, consider publishing its corresponding **GitHub App** or **OAuth App** on the [GitHub App Marketplace](https://github.com/marketplace?type=apps) to ensure that it's discoverable by your audience.
#### Initiating the OAuth Flow from your Client Application
For **GitHub Apps**, details on initiating the OAuth flow from a client application are described in detail [here](https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/generating-a-user-access-token-for-a-github-app#using-the-web-application-flow-to-generate-a-user-access-token).
For **OAuth Apps**, details on initiating the OAuth flow from a client application are described in detail [here](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps#web-application-flow).
> [!IMPORTANT]
> For endpoint discovery, be sure to honor the [`WWW-Authenticate` information provided](https://modelcontextprotocol.io/specification/draft/basic/authorization#authorization-server-location) by the Remote GitHub MCP Server rather than relying on hard-coded endpoints like `https://github.com/login/oauth/authorize`.
### Handling Organization Access Restrictions
Organizations may block **GitHub Apps** and **OAuth Apps** until explicitly approved. Within your client application code, you can provide actionable next steps for a smooth user experience in the event that OAuth-related calls fail due to your **GitHub App** or **OAuth App** being unavailable (i.e. not registered within the user's organization).
1. Detect the specific error.
2. Notify the user clearly.
3. Depending on their GitHub organization privileges:
- Org Members: Prompt them to request approval from a GitHub organization admin, within the organization where access has not been approved.
- Org Admins: Link them to the corresponding GitHub organizations App approval settings at `https://github.com/organizations/[ORG_NAME]/settings/oauth_application_policy`
## Essential Security Considerations
- **Token Storage**: Use secure platform APIs (e.g. keytar for Node.js).
- **Input Validation**: Sanitize all tool arguments.
- **HTTPS Only**: Never send requests over plaintext HTTP. Always use HTTPS in production.
- **PKCE:** We strongly recommend implementing [PKCE](https://datatracker.ietf.org/doc/html/rfc7636) for all OAuth flows to prevent code interception, to prepare for upcoming PKCE support.
## Additional Resources
- [MCP Official Spec](https://modelcontextprotocol.io/specification/draft)
- [MCP SDKs](https://modelcontextprotocol.io/sdk/java/mcp-overview)
- [GitHub Docs on Creating GitHub Apps](https://docs.github.com/en/apps/creating-github-apps)
- [GitHub Docs on Using GitHub Apps](https://docs.github.com/en/apps/using-github-apps/about-using-github-apps)
- [GitHub Docs on Creating OAuth Apps](https://docs.github.com/en/apps/oauth-apps)
- GitHub Docs on Installing OAuth Apps into a [Personal Account](https://docs.github.com/en/apps/oauth-apps/using-oauth-apps/installing-an-oauth-app-in-your-personal-account) and [Organization](https://docs.github.com/en/apps/oauth-apps/using-oauth-apps/installing-an-oauth-app-in-your-organization)
- [Managing OAuth Apps at the Organization Level](https://docs.github.com/en/organizations/managing-oauth-access-to-your-organizations-data)
- [Managing Programmatic Access at the GitHub Organization Level](https://docs.github.com/en/organizations/managing-programmatic-access-to-your-organization)
- [Building Copilot Extensions](https://docs.github.com/en/copilot/building-copilot-extensions)
- [Managing App/Extension Visibility](https://docs.github.com/en/copilot/building-copilot-extensions/managing-the-availability-of-your-copilot-extension) (including GitHub Marketplace information)
- [Example Implementation in VS Code Repository](https://github.com/microsoft/vscode/blob/main/src/vs/workbench/api/common/extHostMcp.ts#L313)
+218
View File
@@ -0,0 +1,218 @@
# Insiders Features
Insiders Mode gives you access to experimental features in the GitHub MCP Server. These features may change, evolve, or be removed based on community feedback.
We created this mode to have a way to roll out experimental features and collect feedback. So if you are using Insiders, please don't hesitate to share your feedback with us!
> [!NOTE]
> Features in Insiders Mode are experimental.
## Enabling Insiders Mode
| Method | Remote Server | Local Server |
|--------|---------------|--------------|
| URL path | Append `/insiders` to the URL | N/A |
| Header | `X-MCP-Insiders: true` | N/A |
| CLI flag | N/A | `--insiders` |
| Environment variable | N/A | `GITHUB_INSIDERS=true` |
For configuration examples, see the [Server Configuration Guide](./server-configuration.md#insiders-mode).
---
## Tools added or changed by Insiders Mode
The list below is generated from the Go source. It covers tool **inventory and schema deltas** introduced by each Insiders feature flag — newly registered tools, or existing tools whose input schema or MCP metadata changes when the flag is on. Flags that only affect runtime behavior (e.g. output formatting or extra field lookups behind an existing schema) won't appear here; those are documented in the prose sections of this file.
<!-- START AUTOMATED INSIDERS TOOLS -->
### `remote_mcp_ui_apps`
- **create_pull_request** - Open new pull request
- **Required OAuth Scopes**: `repo`
- **MCP App UI**: `ui://github-mcp-server/pr-write`
- `base`: Branch to merge into (string, required)
- `body`: PR description (string, optional)
- `draft`: Create as draft PR (boolean, optional)
- `head`: Branch containing changes (string, required)
- `maintainer_can_modify`: Allow maintainer edits (boolean, optional)
- `owner`: Repository owner (string, required)
- `repo`: Repository name (string, required)
- `reviewers`: GitHub usernames or ORG/team-slug team reviewers to request reviews from (string[], optional)
- `title`: PR title (string, required)
- **get_me** - Get my user profile
- **MCP App UI**: `ui://github-mcp-server/get-me`
- No parameters required
- **issue_write** - Create or update issue/pull request
- **Required OAuth Scopes**: `repo`
- **MCP App UI**: `ui://github-mcp-server/issue-write`
- `assignees`: Usernames to assign to this issue (string[], optional)
- `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)
- `issue_fields`: Issue field values to set or clear. Each item requires 'field_name' and exactly one of 'value', 'field_option_name', or 'delete: true'. (object[], optional)
- `issue_number`: Issue number to update (number, optional)
- `labels`: Labels to apply to this issue (string[], optional)
- `method`: Write operation to perform on a single issue.
Options are:
- 'create' - creates a new issue.
- 'update' - updates an existing issue.
(string, required)
- `milestone`: Milestone number (number, optional)
- `owner`: Repository owner (string, required)
- `repo`: Repository name (string, required)
- `state`: New state (string, optional)
- `state_reason`: Reason for the state change. Ignored unless state is changed. (string, optional)
- `title`: Issue title (string, optional)
- `type`: Type of this issue. Only use if issue types are enabled for this repository. Use list_issue_types tool to get valid type values for this repository or its owner organization. If the repository doesn't support issue types, omit this parameter. (string, optional)
- **ui_get** - Get UI data
- **Required OAuth Scopes (any of)**: `repo`, `read:org`
- **Accepted OAuth Scopes**: `admin:org`, `read:org`, `repo`, `write:org`
- `method`: The type of data to fetch (string, required)
- `owner`: Repository owner (required for all methods) (string, required)
- `repo`: Repository name (required for labels, assignees, milestones, branches, issue fields, reviewers) (string, optional)
- **update_pull_request** - Edit pull request
- **Required OAuth Scopes**: `repo`
- **MCP App UI**: `ui://github-mcp-server/pr-edit`
- `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)
- `maintainer_can_modify`: Allow maintainer edits (boolean, optional)
- `owner`: Repository owner (string, required)
- `pullNumber`: Pull request number to update (number, required)
- `repo`: Repository name (string, required)
- `reviewers`: GitHub usernames or ORG/team-slug team reviewers to request reviews from (string[], optional)
- `state`: New state (string, optional)
- `title`: New title (string, optional)
### `file_blame`
- **get_file_blame** - Get file blame information
- **Required OAuth Scopes**: `repo`
- `after`: Cursor for pagination. Use the cursor from the previous response. (string, optional)
- `end_line`: Optional 1-based ending line of the window of interest. Must be >= start_line when both are provided. (number, optional)
- `owner`: Repository owner (username or organization) (string, required)
- `path`: Path to the file in the repository, relative to the repository root (string, required)
- `perPage`: Results per page for pagination (min 1, max 100) (number, optional)
- `ref`: Git reference (branch, tag, or commit SHA). Defaults to the repository's default branch (HEAD). (string, optional)
- `repo`: Repository name (string, required)
- `start_line`: Optional 1-based starting line of the window of interest. Only ranges overlapping [start_line, end_line] are returned, clamped to the window. (number, optional)
### `issue_dependencies`
- **issue_dependency_read** - Read issue dependencies
- **Required OAuth Scopes**: `repo`
- `issue_number`: The number of the issue (number, required)
- `method`: The read operation to perform on a single issue's dependencies.
Options are:
1. get_blocked_by - List the issues that block this issue (this issue is blocked by them).
2. get_blocking - List the issues that this issue blocks.
(string, required)
- `owner`: The owner of the repository (string, required)
- `page`: Page number for pagination (min 1) (number, optional)
- `perPage`: Results per page for pagination (min 1, max 100) (number, optional)
- `repo`: The name of the repository (string, required)
- **issue_dependency_write** - Change issue dependency
- **Required OAuth Scopes**: `repo`
- `issue_number`: The number of the subject issue (number, required)
- `method`: The action to perform.
Options are:
- 'add' - create the dependency relationship.
- 'remove' - delete the dependency relationship. (string, required)
- `owner`: The owner of the subject issue's repository (string, required)
- `related_issue_number`: The number of the related issue to link or unlink (number, required)
- `related_owner`: The owner of the related issue's repository. Defaults to 'owner' when omitted. (string, optional)
- `related_repo`: The name of the related issue's repository. Defaults to 'repo' when omitted. (string, optional)
- `repo`: The name of the subject issue's repository (string, required)
- `type`: The relationship direction relative to the subject issue.
Options are:
- 'blocked_by' - the subject issue is blocked by the related issue.
- 'blocking' - the subject issue blocks the related issue. (string, required)
<!-- END AUTOMATED INSIDERS TOOLS -->
---
## MCP Apps
[MCP Apps](https://modelcontextprotocol.io/docs/extensions/apps) is an extension to the Model Context Protocol that enables servers to deliver interactive user interfaces to end users. Instead of returning plain text that the LLM must interpret and relay, tools can render forms, profiles, and dashboards right in the chat using MCP Apps.
This means you can interact with GitHub visually: fill out forms to create issues, see user profiles with avatars, open pull requests — all without leaving your agent chat.
### Supported tools
The following tools have MCP Apps UIs:
| Tool | Description |
|------|-------------|
| `get_me` | Displays your GitHub user profile with avatar, bio, and stats in a rich card |
| `issue_write` | Opens an interactive form to create or update issues |
| `create_pull_request` | Provides a full PR creation form to create a pull request (or a draft pull request) |
### Client requirements
MCP Apps requires a host that supports the [MCP Apps extension](https://modelcontextprotocol.io/docs/extensions/apps). Currently tested and working with:
- **VS Code Insiders** — enable via the `chat.mcp.apps.enabled` setting
- **Visual Studio Code** — enable via the `chat.mcp.apps.enabled` setting
---
## CSV output for list tools
CSV output mode returns supported list tool responses as CSV instead of JSON. This is intended to reduce response context for agents when scanning or summarising lists of GitHub data.
CSV output applies only to tools in default toolsets whose names start with `list_`, such as `list_issues`, `list_pull_requests`, `list_commits`, and `list_branches`. It does not add new tools or expose a tool argument for selecting the format; the server controls the response format through the Insiders feature flag.
### Format
- Nested objects are flattened into dot-notation columns, for example `user.login`, `category.name`, or `head.ref`.
- Arrays are represented as compact single-cell values joined with `;`.
- `body` fields are whitespace-normalized so multiline Markdown does not expand a list response into many output lines.
- Response metadata present in wrapped responses, such as `pageInfo.*` and `totalCount`, is emitted as `#`-prefixed lines before the CSV rows, followed by a blank line. Tools that return a root JSON array do not include metadata preamble lines.
### Enabling CSV output
CSV output is enabled by Insiders Mode. For local development, it can also be enabled explicitly with the `csv_output` feature flag:
```bash
github-mcp-server stdio --features csv_output
```
Because this changes list tool response shape, clients that require JSON list responses should avoid enabling this feature.
---
## How feature flags are resolved
> [!NOTE]
> This section is for contributors. End users only need the table at the top of this page.
Insiders is a **meta feature flag** — the same shape as `default` or `all` for toolsets. It expands once at startup into a curated set of individual feature flags, and from that point on every code path keys off concrete flags, never `InsidersMode` directly. New experimental work should always get its own flag and then be added to the insiders expansion list, never folded into `insiders` as a catch-all.
### Resolution order
1. **User input.** Users may opt into specific features:
- Local server: `--features=<flag>,<flag>` CLI flag (or `GITHUB_FEATURES` env var).
- Self-hosted HTTP server: `X-MCP-Features: <flag>,<flag>` request header.
2. **Allowlist filter.** User-supplied flags are filtered against [`AllowedFeatureFlags`](../pkg/github/feature_flags.go). Anything not on the allowlist is silently dropped — flags missing from the allowlist can only be turned on by remote-server feature management, not by end users.
3. **Insiders expansion.** If insiders mode is on (`--insiders`, `/insiders` route, or `X-MCP-Insiders: true`), every flag in [`InsidersFeatureFlags`](../pkg/github/feature_flags.go) is unioned in. The insiders expansion is **not** re-validated against the allowlist — insiders is a server-controlled switch that can reach internal-only flags.
4. **Server-side fallback (remote server only).** Any flag not yet decided falls back to the remote server's feature manager, which can roll a feature out independently of user input or insiders membership.
`AllowedFeatureFlags` and `InsidersFeatureFlags` are deliberately independent sets:
- A flag in **`AllowedFeatureFlags` only** is a regular opt-in: users can turn it on, but insiders does not auto-enable it. Granular issues/PRs flags work this way.
- A flag in **`InsidersFeatureFlags` only** is reachable through insiders (and remote-server rollouts), but cannot be enabled by user input. Internal-only experiments work this way.
- A flag in **both** is opt-in for end users *and* automatically on under insiders.
### Adding a new feature flag
1. Add a constant in `pkg/github/feature_flags.go`.
2. Add it to `AllowedFeatureFlags` if end users should be able to opt in via `--features` / `X-MCP-Features`.
3. Add it to `InsidersFeatureFlags` if insiders mode should turn it on automatically.
4. Gate the behavior on the concrete flag (`deps.IsFeatureEnabled(ctx, FeatureFlagX)`), never on `cfg.InsidersMode`. There is a `TestGitHubPackageDoesNotReadInsidersMode` guard test that fails if `pkg/github` reads `InsidersMode` directly.
5. The MCP-diff CI workflow picks up new entries in `AllowedFeatureFlags` automatically — see `.github/workflows/mcp-diff.yml`.
+112
View File
@@ -0,0 +1,112 @@
# GitHub MCP Server Installation Guides
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 Desktop and Claude Code CLI
- **[Cline](install-cline.md)** - Installation guide for Cline
- **[Cursor](install-cursor.md)** - Installation guide for Cursor IDE
- **[Google Gemini CLI](install-gemini-cli.md)** - Installation guide for Google Gemini CLI
- **[OpenAI Codex](install-codex.md)** - Installation guide for OpenAI Codex
- **[OpenCode](install-opencode.md)** - Installation guide for the OpenCode terminal agent
- **[Roo Code](install-roo-code.md)** - Installation guide for Roo Code
- **[Windsurf](install-windsurf.md)** - Installation guide for Windsurf IDE
- **[Xcode (Codex & Claude Agent)](install-xcode.md)** - Installation guide for Codex and Claude Agent within Xcode
- **[Zed](install-zed.md)** - Installation guide for Zed editor
## Support by Host Application
| 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<br>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<br>Remote: Visual Studio 17.14+ | Easy |
| Copilot in JetBrains | ✅ | ✅ Full (OAuth + PAT) | Local: Docker or Go build, GitHub PAT<br>Remote: JetBrains Copilot Extension v1.5.53+ | Easy |
| Claude Code | ✅ | ✅ PAT + ❌ No OAuth| GitHub MCP Server binary or remote URL, GitHub PAT | Easy |
| Claude Desktop | ✅ | ✅ PAT + ❌ No OAuth | Docker or Go build, GitHub PAT | Moderate |
| Cline | ✅ | ✅ PAT + ❌ No OAuth | Docker or Go build, GitHub PAT | Easy |
| Cursor | ✅ | ✅ PAT + ❌ No OAuth | Docker or Go build, GitHub PAT | Easy |
| Google Gemini CLI | ✅ | ✅ PAT + ❌ No OAuth | Docker or Go build, GitHub PAT | Easy |
| OpenCode | ✅ | ✅ PAT + ❌ No OAuth | Docker or Go build, GitHub PAT | Easy |
| Roo Code | ✅ | ✅ PAT + ❌ No OAuth | Docker or Go build, GitHub PAT | Easy |
| Windsurf | ✅ | ✅ PAT + ❌ No OAuth | Docker or Go build, GitHub PAT | Easy |
| Zed | ✅ | ✅ PAT + ❌ No OAuth | Docker or Go build, GitHub PAT | Easy |
| Copilot in Xcode | ✅ | ✅ Full (OAuth + PAT) | Local: Docker or Go build, GitHub PAT<br>Remote: Copilot for Xcode 0.41.0+ | Easy |
| Copilot in Eclipse | ✅ | ✅ Full (OAuth + PAT) | Local: Docker or Go build, GitHub PAT<br>Remote: Eclipse Plug-in for Copilot 0.10.0+ | Easy |
| Xcode (Codex) | ✅ | ✅ PAT + ❌ No OAuth | Local: Docker (full path required), GitHub PAT<br>Remote: GitHub PAT via `GITHUB_PAT_TOKEN` env var (`bearer_token_env_var`) | Easy |
| Xcode (Claude Agent) | ✅ | ✅ PAT + ❌ No OAuth | Local: Docker (full path required), GitHub PAT<br>Remote: GitHub PAT | Easy |
**Legend:**
- ✅ = Fully supported
- ❌ = Not yet supported
**Note:** Remote MCP support requires host applications to register a GitHub App or OAuth app for OAuth flow support even if the new OAuth spec is supported by that host app. Currently, only VS Code has full remote GitHub server support.
## Installation Methods
The GitHub MCP Server can be installed using several methods. **Docker is the most popular and recommended approach** for most users, but alternatives are available depending on your needs:
### 🐳 Docker (Most Common & Recommended)
- **Pros**: No local build required, consistent environment, easy updates, works across all platforms
- **Cons**: Requires Docker installed and running
- **Best for**: Most users, especially those already using Docker or wanting the simplest setup
- **Used by**: Claude Desktop, Copilot in VS Code, Cursor, Windsurf, etc.
### 📦 Pre-built Binary (Lightweight Alternative)
- **Pros**: No Docker required, direct execution via stdio, minimal setup
- **Cons**: Need to manually download and manage updates, platform-specific binaries
- **Best for**: Minimal environments, users who prefer not to use Docker
- **Used by**: Claude Code CLI, lightweight setups
### 🔨 Build from Source (Advanced Users)
- **Pros**: Latest features, full customization, no external dependencies
- **Cons**: Requires Go development environment, more complex setup
- **Prerequisites**: [Go 1.24+](https://go.dev/doc/install)
- **Build command**: `go build -o github-mcp-server cmd/github-mcp-server/main.go`
- **Best for**: Developers who want the latest features or need custom modifications
### Important Notes on the GitHub MCP Server
- **Docker Image**: The official Docker image is now `ghcr.io/github/github-mcp-server`
- **npm Package**: The npm package @modelcontextprotocol/server-github is no longer supported as of April 2025
- **Remote Server**: The remote server URL is `https://api.githubcopilot.com/mcp/`
## General Prerequisites
All installations with Personal Access Tokens (PAT) require:
- **GitHub Personal Access Token (PAT)**: [Create one here](https://github.com/settings/personal-access-tokens/new)
Optional (depending on installation method):
- **Docker** (for Docker-based installations): [Download Docker](https://www.docker.com/)
- **Go 1.24+** (for building from source): [Install Go](https://go.dev/doc/install)
## Security Best Practices
Regardless of which installation method you choose, follow these security guidelines:
1. **Secure Token Storage**: Never commit your GitHub PAT to version control
2. **Limit Token Scope**: Only grant necessary permissions to your GitHub PAT
3. **File Permissions**: Restrict access to configuration files containing tokens
4. **Regular Rotation**: Periodically rotate your GitHub Personal Access Tokens
5. **Environment Variables**: Use environment variables when supported by your host
## Getting Help
If you encounter issues:
1. Check the troubleshooting section in your specific installation guide
2. Verify your GitHub PAT has the required permissions
3. Ensure Docker is running (for local installations)
4. Review your host application's logs for error messages
5. Consult the main [README.md](README.md) for additional configuration options
## Configuration Options
After installation, you may want to explore:
- **Toolsets**: Enable/disable specific GitHub API capabilities
- **Read-Only Mode**: Restrict to read-only operations
- **Lockdown Mode**: Hide public issue details created by users without push access
@@ -0,0 +1,172 @@
# Installing GitHub MCP Server in Antigravity
This guide covers setting up the GitHub MCP Server in Google's Antigravity IDE.
## Prerequisites
- Antigravity IDE installed (latest version)
- GitHub Personal Access Token with appropriate scopes
## Installation Methods
### Option 1: Remote Server (Recommended)
Uses GitHub's hosted server at `https://api.githubcopilot.com/mcp/`.
> [!NOTE]
> We recommend this manual configuration method because the "official" installation via the Antigravity MCP Store currently has known issues (often resulting in Docker errors). This direct remote connection is more reliable.
#### Step 1: Access MCP Configuration
1. Open Antigravity
2. Click the "..." (Additional Options) menu in the Agent panel
3. Select "MCP Servers"
4. Click "Manage MCP Servers"
5. Click "View raw config"
This will open your `mcp_config.json` file at:
- **Windows**: `C:\Users\<USERNAME>\.gemini\antigravity\mcp_config.json`
- **macOS/Linux**: `~/.gemini/antigravity/mcp_config.json`
#### Step 2: Add Configuration
Add the following to your `mcp_config.json`:
```json
{
"mcpServers": {
"github": {
"serverUrl": "https://api.githubcopilot.com/mcp/",
"headers": {
"Authorization": "Bearer YOUR_GITHUB_PAT"
}
}
}
}
```
**Important**: Note that Antigravity uses `serverUrl` instead of `url` for HTTP-based MCP servers.
#### Step 3: Configure Your Token
Replace `YOUR_GITHUB_PAT` with your actual GitHub Personal Access Token.
Create a token here: https://github.com/settings/tokens
Recommended scopes:
- `repo` - Full control of private repositories
- `read:org` - Read org and team membership
- `read:user` - Read user profile data
#### Step 4: Restart Antigravity
Close and reopen Antigravity for the changes to take effect.
#### Step 5: Verify Installation
1. Open the MCP Servers panel (... menu → MCP Servers)
2. You should see "github" with a list of available tools
3. You can now use GitHub tools in your conversations
> [!NOTE]
> The status indicator in the MCP Servers panel might not immediately turn green in some versions, but the tools will still function if configured correctly.
### Option 2: Local Docker Server
If you prefer running the server locally with Docker:
Log in with OAuth instead of a token. On github.com the official image already includes the app credentials, so you provide none yourself — the server opens a browser login on first use and keeps the token in memory only. In Docker, publish a fixed callback port to loopback:
```json
{
"mcpServers": {
"github": {
"command": "docker",
"args": [
"run",
"-i",
"--rm",
"-p",
"127.0.0.1:8085:8085",
"-e",
"GITHUB_OAUTH_CALLBACK_PORT",
"ghcr.io/github/github-mcp-server"
],
"env": {
"GITHUB_OAUTH_CALLBACK_PORT": "8085"
}
}
}
}
```
See **[Local Server OAuth Login](../oauth-login.md)** for the native-binary flow (no fixed port), headless/device-code fallback, GitHub Enterprise, and bringing your own OAuth or GitHub App.
To authenticate with a Personal Access Token instead (it takes precedence over OAuth):
```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": "YOUR_GITHUB_PAT"
}
}
}
}
```
**Requirements**:
- Docker Desktop installed and running
- Docker must be in your system PATH
## Troubleshooting
### "Error: serverUrl or command must be specified"
Make sure you're using `serverUrl` (not `url`) for the remote server configuration. Antigravity requires `serverUrl` for HTTP-based MCP servers.
### Server not appearing in MCP list
- Verify JSON syntax in your config file
- Check that your PAT hasn't expired
- Restart Antigravity completely
### Tools not working
- Ensure your PAT has the correct scopes
- Check the MCP Servers panel for error messages
- Verify internet connection for remote server
## Available Tools
Once installed, you'll have access to tools like:
- `create_repository` - Create new GitHub repositories
- `push_files` - Push files to repositories
- `search_repositories` - Search for repositories
- `create_or_update_file` - Manage file content
- `get_file_contents` - Read file content
- And many more...
For a complete list of available tools and features, see the [main README](../../README.md).
## Differences from Other IDEs
- **Configuration key**: Antigravity uses `serverUrl` instead of `url` for HTTP servers
- **Config location**: `.gemini/antigravity/mcp_config.json` instead of `.cursor/mcp.json`
- **Tool limits**: Antigravity recommends keeping total enabled tools under 50 for optimal performance
## Next Steps
- Explore the [Server Configuration Guide](../server-configuration.md) for advanced options
- Check out [toolsets documentation](../../README.md#available-toolsets) to customize available tools
- See the [Remote Server Documentation](../remote-server.md) for more details
+342
View File
@@ -0,0 +1,342 @@
# Install GitHub MCP Server in Claude Applications
## Claude Code CLI
### Prerequisites
- Claude Code CLI installed
- [GitHub Personal Access Token](https://github.com/settings/personal-access-tokens/new)
- For local setup: [Docker](https://www.docker.com/) installed and running
- Open Claude Code inside the directory for your project (recommended for best experience and clear scope of configuration)
<details>
<summary><b>Storing Your PAT Securely</b></summary>
<br>
For security, avoid hardcoding your token. One common approach:
1. Store your token in `.env` file
```
GITHUB_PAT=your_token_here
```
2. Add to .gitignore
```bash
echo -e ".env\n.mcp.json" >> .gitignore
```
</details>
### Remote Server Setup (Streamable HTTP)
> **Note**: For Claude Code versions **2.1.1 and newer**, use the `add-json` command format below. For older versions, see the [legacy command format](#for-older-versions-of-claude-code).
>
> **Windows / CLI note**: `claude mcp add-json` may return `Invalid input` when adding an HTTP server. If that happens, use the legacy `claude mcp add --transport http ...` command format below.
1. Run the following command in the terminal (not in Claude Code CLI):
```bash
claude mcp add-json github '{"type":"http","url":"https://api.githubcopilot.com/mcp","headers":{"Authorization":"Bearer YOUR_GITHUB_PAT"}}'
```
With an environment variable (Linux/macOS):
```bash
export GITHUB_PAT="$(grep '^GITHUB_PAT=' .env | cut -d '=' -f2-)"
claude mcp add-json github '{"type":"http","url":"https://api.githubcopilot.com/mcp","headers":{"Authorization":"Bearer '"$GITHUB_PAT"'"}}'
```
With an environment variable (Windows PowerShell):
```powershell
$githubPatLine = Get-Content .env | Select-String "^\s*GITHUB_PAT\s*=" | Select-Object -First 1
$env:GITHUB_PAT = ($githubPatLine.Line -split "=", 2)[1].Trim().Trim('"').Trim("'")
claude mcp add-json github "{`"type`":`"http`",`"url`":`"https://api.githubcopilot.com/mcp`",`"headers`":{`"Authorization`":`"Bearer $env:GITHUB_PAT`"}}"
```
> **About the `--scope` flag** (optional): Use this to specify where the configuration is stored:
> - `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
Log in with OAuth instead of a token. On github.com the official image already includes the app credentials, so you provide none yourself — the server opens a browser login on first use and keeps the token in memory only. In Docker, publish a fixed callback port to loopback. Run the following command in the terminal (not in Claude Code CLI):
```bash
claude mcp add github -e GITHUB_OAUTH_CALLBACK_PORT=8085 -- docker run -i --rm -p 127.0.0.1:8085:8085 -e GITHUB_OAUTH_CALLBACK_PORT ghcr.io/github/github-mcp-server
```
See **[Local Server OAuth Login](../oauth-login.md)** for the native-binary flow (no fixed port), headless/device-code fallback, GitHub Enterprise, and bringing your own OAuth or GitHub App.
To authenticate with a Personal Access Token instead (it takes precedence over OAuth):
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
```
With an environment variable:
```bash
claude mcp add github -e GITHUB_PERSONAL_ACCESS_TOKEN=$(grep GITHUB_PAT .env | cut -d '=' -f2) -- docker run -i --rm -e GITHUB_PERSONAL_ACCESS_TOKEN ghcr.io/github/github-mcp-server
```
2. Restart Claude Code
3. Run `claude mcp list` to see if the GitHub server is configured
### With a Binary (no Docker)
1. Download [release binary](https://github.com/github/github-mcp-server/releases)
2. Add to your `PATH`
3. Run:
```bash
claude mcp add-json github '{"command": "github-mcp-server", "args": ["stdio"], "env": {"GITHUB_PERSONAL_ACCESS_TOKEN": "YOUR_GITHUB_PAT"}}'
```
2. Restart Claude Code
3. Run `claude mcp list` to see if the GitHub server is configured
### Verification
```bash
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
> ⚠️ **Note**: Some users have reported compatibility issues with Claude Desktop and Docker-based MCP servers. We're investigating. If you experience issues, try using another MCP host, while we look into it!
### Prerequisites
- Claude Desktop installed (latest version)
- [GitHub Personal Access Token](https://github.com/settings/personal-access-tokens/new)
- [Docker](https://www.docker.com/) installed and running
> **Note**: Claude Desktop supports MCP servers that are both local (stdio) and remote ("connectors"). Remote servers can generally be added via Settings → Connectors → "Add custom connector". However, the GitHub remote MCP server requires OAuth authentication through a registered GitHub App (or OAuth App), which is not currently supported. Use the local Docker setup instead.
### Configuration File Location
- **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json`
- **Windows**: `%APPDATA%\Claude\claude_desktop_config.json`
- **Linux**: `~/.config/Claude/claude_desktop_config.json`
### Local Server Setup (Docker)
Add this codeblock to your `claude_desktop_config.json`:
Log in with OAuth instead of a token. On github.com the official image already includes the app credentials, so you provide none yourself — the server opens a browser login on first use and keeps the token in memory only. In Docker, publish a fixed callback port to loopback:
```json
{
"mcpServers": {
"github": {
"command": "docker",
"args": [
"run",
"-i",
"--rm",
"-p",
"127.0.0.1:8085:8085",
"-e",
"GITHUB_OAUTH_CALLBACK_PORT",
"ghcr.io/github/github-mcp-server"
],
"env": {
"GITHUB_OAUTH_CALLBACK_PORT": "8085"
}
}
}
}
```
See **[Local Server OAuth Login](../oauth-login.md)** for the native-binary flow (no fixed port), headless/device-code fallback, GitHub Enterprise, and bringing your own OAuth or GitHub App.
To authenticate with a Personal Access Token instead (it takes precedence over OAuth):
```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": "YOUR_GITHUB_PAT"
}
}
}
}
```
### Manual Setup Steps
1. Open Claude Desktop
2. Go to Settings → Developer → Edit Config
3. Paste the code block above in your configuration file
4. If you're navigating to the configuration file outside of the app:
- **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json`
- **Windows**: `%APPDATA%\Claude\claude_desktop_config.json`
5. Open the file in a text editor
6. Paste one of the code blocks above, based on your chosen configuration (remote or local)
7. Replace `YOUR_GITHUB_PAT` with your actual token or $GITHUB_PAT environment variable
8. Save the file
9. Restart Claude Desktop
---
## Xcode (Claude Agent)
Xcode's Claude Agent uses the same `.claude.json` configuration format as the Claude Code CLI, but reads it from an Xcode-specific directory rather than the global config location.
### Configuration File Location
```
~/Library/Developer/Xcode/CodingAssistant/ClaudeAgentConfig/.claude.json
```
> Configurations placed here only affect Claude Agent when launched from Xcode. See [Apple's documentation](https://developer.apple.com/documentation/xcode/setting-up-coding-intelligence#Customize-the-Claude-Agent-and-Codex-environments) for more details.
### Remote Server Setup (Recommended)
Run the following command in Terminal to add the remote GitHub MCP server:
```bash
claude mcp add-json github '{"type":"http","url":"https://api.githubcopilot.com/mcp/","headers":{"Authorization":"Bearer YOUR_GITHUB_PAT"}}' --config ~/Library/Developer/Xcode/CodingAssistant/ClaudeAgentConfig/.claude.json
```
Or open the file in a text editor and add the `mcpServers` block manually:
```json
{
"mcpServers": {
"github": {
"type": "http",
"url": "https://api.githubcopilot.com/mcp/",
"headers": {
"Authorization": "Bearer YOUR_GITHUB_PAT"
}
}
}
}
```
### Local Server Setup (Docker)
> **macOS note**: Xcode runs with a minimal `PATH` that typically excludes `/usr/local/bin` (Intel) and `/opt/homebrew/bin` (Apple Silicon). Use the full path to `docker` to ensure it can be found. Run `which docker` in Terminal to find the correct path on your system.
Log in with OAuth instead of a token. On github.com the official image already includes the app credentials, so you provide none yourself — the server opens a browser login on first use and keeps the token in memory only. In Docker, publish a fixed callback port to loopback:
```json
{
"mcpServers": {
"github": {
"command": "/usr/local/bin/docker",
"args": [
"run",
"-i",
"--rm",
"-p",
"127.0.0.1:8085:8085",
"-e",
"GITHUB_OAUTH_CALLBACK_PORT",
"ghcr.io/github/github-mcp-server"
],
"env": {
"GITHUB_OAUTH_CALLBACK_PORT": "8085"
}
}
}
}
```
See **[Local Server OAuth Login](../oauth-login.md)** for the native-binary flow (no fixed port), headless/device-code fallback, GitHub Enterprise, and bringing your own OAuth or GitHub App.
To authenticate with a Personal Access Token instead (it takes precedence over OAuth):
```json
{
"mcpServers": {
"github": {
"command": "/usr/local/bin/docker",
"args": [
"run",
"-i",
"--rm",
"-e",
"GITHUB_PERSONAL_ACCESS_TOKEN",
"ghcr.io/github/github-mcp-server"
],
"env": {
"GITHUB_PERSONAL_ACCESS_TOKEN": "YOUR_GITHUB_PAT"
}
}
}
}
```
### Setup Steps
1. Create or open `~/Library/Developer/Xcode/CodingAssistant/ClaudeAgentConfig/.claude.json`
2. Add the configuration block above
3. Replace `YOUR_GITHUB_PAT` with your actual token
4. Restart Xcode
---
**Authentication Failed:**
- Verify PAT has `repo` scope
- Check token hasn't expired
**Remote Server:**
- Verify URL: `https://api.githubcopilot.com/mcp`
**Docker Issues (Local Only):**
- Ensure Docker Desktop is running
- Try: `docker pull ghcr.io/github/github-mcp-server`
- If pull fails: `docker logout ghcr.io` then retry
**Server Not Starting / Tools Not Showing:**
- Run `claude mcp list` to view currently configured MCP servers
- Validate JSON syntax
- If using an environment variable to store your PAT, make sure you're properly sourcing your PAT using the environment variable
- Restart Claude Code and check `/mcp` command
- Delete the GitHub server by running `claude mcp remove github` and repeating the setup process with a different method
- Make sure you're running Claude Code within the project you're currently working on to ensure the MCP configuration is properly scoped to your project
- Check logs:
- Claude Code: Use `/mcp` command
- Claude Desktop: `ls ~/Library/Logs/Claude/` and `cat ~/Library/Logs/Claude/mcp-server-*.log` (macOS) or `%APPDATA%\Claude\logs\` (Windows)
---
## Important Notes
- The npm package `@modelcontextprotocol/server-github` is deprecated as of April 2025
- Remote server requires Streamable HTTP support (check your Claude version)
- For Claude Code configuration scopes, see the `--scope` flag documentation in the [Remote Server Setup](#remote-server-setup-streamable-http) section
+81
View File
@@ -0,0 +1,81 @@
# Install GitHub MCP Server in Cline
[Cline](https://github.com/cline/cline) is an AI coding assistant that runs in VS Code-compatible editors (VS Code, Cursor, Windsurf, etc.). For general setup information (prerequisites, Docker installation, security best practices), see the [Installation Guides README](./README.md).
## Remote Server
Cline stores MCP settings in `cline_mcp_settings.json`. To edit it, click the Cline icon in your editor's sidebar, open the menu in the top right corner of the Cline panel, and select **"MCP Servers"**. You can add a remote server through the **"Remote Servers"** tab, or click **"Configure MCP Servers"** to edit the JSON directly.
```json
{
"mcpServers": {
"github": {
"url": "https://api.githubcopilot.com/mcp/",
"type": "streamableHttp",
"disabled": false,
"headers": {
"Authorization": "Bearer <YOUR_GITHUB_PAT>"
},
"autoApprove": []
}
}
}
```
Replace `YOUR_GITHUB_PAT` with your [GitHub Personal Access Token](https://github.com/settings/tokens). To customize toolsets, add server-side headers like `X-MCP-Toolsets` or `X-MCP-Readonly` to the `headers` object — see [Server Configuration Guide](../server-configuration.md).
> **Important:** The transport type must be `"streamableHttp"` (camelCase, no hyphen). Using `"streamable-http"` or omitting the type will cause Cline to fall back to SSE, resulting in a `405` error.
## Local Server (Docker)
1. Click the Cline icon in your editor's sidebar (or open the command palette and search for "Cline"), then click the **MCP Servers** icon (server stack icon at the top of the Cline panel), and click **"Configure MCP Servers"** to open `cline_mcp_settings.json`.
2. Add one of the configurations below. The OAuth option needs no token; for the PAT option, replace `YOUR_GITHUB_PAT` with your [GitHub Personal Access Token](https://github.com/settings/tokens).
Log in with OAuth instead of a token. On github.com the official image already includes the app credentials, so you provide none yourself — the server opens a browser login on first use and keeps the token in memory only. In Docker, publish a fixed callback port to loopback:
```json
{
"mcpServers": {
"github": {
"command": "docker",
"args": [
"run", "-i", "--rm",
"-p", "127.0.0.1:8085:8085",
"-e", "GITHUB_OAUTH_CALLBACK_PORT",
"ghcr.io/github/github-mcp-server"
],
"env": {
"GITHUB_OAUTH_CALLBACK_PORT": "8085"
}
}
}
}
```
See **[Local Server OAuth Login](../oauth-login.md)** for the native-binary flow (no fixed port), headless/device-code fallback, GitHub Enterprise, and bringing your own OAuth or GitHub App.
To authenticate with a Personal Access Token instead (it takes precedence over OAuth):
```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": "YOUR_GITHUB_PAT"
}
}
}
}
```
## Troubleshooting
- **SSE error 405 with remote server**: Ensure `"type"` is set to `"streamableHttp"` (camelCase, no hyphen) in `cline_mcp_settings.json`. Using `"streamable-http"` or omitting `"type"` causes Cline to fall back to SSE, which this server does not support.
- **Authentication failures**: Verify your PAT has the required scopes
- **Docker issues**: Ensure Docker Desktop is installed and running
+134
View File
@@ -0,0 +1,134 @@
# Install GitHub MCP Server in OpenAI Codex
## Prerequisites
1. OpenAI Codex (MCP-enabled) installed / available
2. A [GitHub Personal Access Token](https://github.com/settings/personal-access-tokens/new)
> The remote GitHub MCP server is hosted by GitHub at `https://api.githubcopilot.com/mcp/` and supports Streamable HTTP.
## Remote Configuration
Edit `~/.codex/config.toml` (shared by CLI and IDE extension) and add:
```toml
[mcp_servers.github]
url = "https://api.githubcopilot.com/mcp/"
# Replace with your real PAT (least-privilege scopes). Do NOT commit this.
bearer_token_env_var = "GITHUB_PAT_TOKEN"
```
You can also add it via the Codex CLI:
```bash
codex mcp add github --url https://api.githubcopilot.com/mcp/ --bearer-token-env-var GITHUB_PAT_TOKEN
```
The `--bearer-token-env-var` option is required for PAT-authenticated access to the hosted GitHub MCP server.
<details>
<summary><b>Storing Your PAT Securely</b></summary>
<br>
For security, avoid hardcoding your token. One common approach:
1. Store your token in `.env` file
```
GITHUB_PAT_TOKEN=ghp_your_token_here
```
2. Add to .gitignore
```bash
echo -e ".env" >> .gitignore
```
</details>
## Local Docker Configuration
Use this if you prefer a local, self-hosted instance instead of the remote HTTP server. See the [OpenAI documentation for configuration](https://developers.openai.com/codex/mcp) for the authoritative schema.
Log in with OAuth instead of a token. On github.com the official image already includes the app credentials, so you provide none yourself — the server opens a browser login on first use and keeps the token in memory only. In Docker, publish a fixed callback port to loopback:
```toml
[mcp_servers.github]
command = "docker"
args = ["run", "-i", "--rm", "-p", "127.0.0.1:8085:8085", "-e", "GITHUB_OAUTH_CALLBACK_PORT", "ghcr.io/github/github-mcp-server"]
env = { GITHUB_OAUTH_CALLBACK_PORT = "8085" }
```
See **[Local Server OAuth Login](../oauth-login.md)** for the native-binary flow (no fixed port), headless/device-code fallback, GitHub Enterprise, and bringing your own OAuth or GitHub App.
To authenticate with a Personal Access Token instead (it takes precedence over OAuth):
```toml
[mcp_servers.github]
command = "docker"
args = ["run", "-i", "--rm", "-e", "GITHUB_PERSONAL_ACCESS_TOKEN", "ghcr.io/github/github-mcp-server"]
env = { GITHUB_PERSONAL_ACCESS_TOKEN = "ghp_your_token_here" }
```
## Verification
After starting Codex (CLI or IDE):
1. Run `/mcp` in the TUI or use the IDE MCP panel; confirm `github` shows tools.
2. Ask: "List my GitHub repositories".
3. If tools are missing:
- Check token validity & scopes.
- Confirm correct table name: `[mcp_servers.github]`.
## Usage
After setup, Codex can interact with GitHub directly. It will use the default tool set automatically but can be [configured](../../README.md#default-toolset). Try these example prompts:
**Repository Operations:**
- "List my GitHub repositories"
- "Show me recent issues in [owner/repo]"
- "Create a new issue in [owner/repo] titled 'Bug: fix login'"
**Pull Requests:**
- "List open pull requests in [owner/repo]"
- "Show me the diff for PR #123"
- "Add a comment to PR #123: 'LGTM, approved'"
**Actions & Workflows:**
- "Show me recent workflow runs in [owner/repo]"
- "Trigger the 'deploy' workflow in [owner/repo]"
**Gists:**
- "Create a gist with this code snippet"
- "List my gists"
> **Tip**: Use `/mcp` in the Codex UI to see all available GitHub tools and their descriptions.
## Choosing Scopes for Your PAT
Minimal useful scopes (adjust as needed):
- `repo` (general repository operations)
- `workflow` (if you want Actions workflow access)
- `read:org` (if accessing org-level resources)
- `project` (for classic project boards)
- `gist` (if using gist tools)
Use the principle of least privilege: add scopes only when a tool request fails due to permission.
## Troubleshooting
| Issue | Possible Cause | Fix |
|-------|----------------|-----|
| Authentication failed | Missing/incorrect PAT scope | Regenerate PAT; ensure `repo` scope present |
| 401 Unauthorized (remote) | Token expired/revoked | Create new PAT; update `bearer_token_env_var` |
| Server not listed | Wrong table name or syntax error | Use `[mcp_servers.github]`; validate TOML |
| Tools missing / zero tools | Insufficient PAT scopes | Add needed scopes (workflow, gist, etc.) |
| Token in file risks leakage | Committed accidentally | Rotate token; add file to `.gitignore` |
## Security Best Practices
1. Never commit tokens into version control
3. Rotate tokens periodically
4. Restrict scopes up front; expand only when required
5. Remove unused PATs from your GitHub account
## References
- Remote server URL: `https://api.githubcopilot.com/mcp/`
- Release binaries: [GitHub Releases](https://github.com/github/github-mcp-server/releases)
- OpenAI Codex MCP docs: https://developers.openai.com/codex/mcp
- Main project README: [Advanced configuration options](../../README.md)
@@ -0,0 +1,201 @@
# Install GitHub MCP Server in Copilot CLI
The GitHub MCP server comes pre-installed in Copilot CLI, with read-only tools enabled by default.
## Built-in Server
To verify the server is available, from an active Copilot CLI session:
```bash
/mcp show github-mcp-server
```
### Per-Session Customization
Use CLI flags to customize the server for a session:
```bash
# Enable an additional toolset
copilot --add-github-mcp-toolset discussions
# Enable multiple additional toolsets
copilot --add-github-mcp-toolset discussions --add-github-mcp-toolset stargazers
# Enable all toolsets
copilot --enable-all-github-mcp-tools
# Enable a specific tool
copilot --add-github-mcp-tool list_discussions
# Disable the built-in server entirely
copilot --disable-builtin-mcps
```
Run `copilot --help` for all available flags. For the list of toolsets, see [Available toolsets](../../README.md#available-toolsets); for the list of tools, see [Tools](../../README.md#tools).
## Custom Configuration
You can configure the GitHub MCP server in Copilot CLI using either the interactive command or by manually editing the configuration file.
> **Server naming:** Name your server `github-mcp-server` to replace the built-in server, or use a different name (e.g., `github`) to run alongside it.
### Prerequisites
1. [GitHub Personal Access Token](https://github.com/settings/personal-access-tokens/new) with appropriate scopes
2. For local server: [Docker](https://www.docker.com/) installed and running
<details>
<summary><b>Storing Your PAT Securely</b></summary>
<br>
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
```
</details>
### Method 1: Interactive Setup (Recommended)
From an active Copilot CLI session, run the interactive command:
```bash
/mcp add
```
Follow the prompts to configure the server.
### Method 2: Manual Setup
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": {
"type": "http",
"url": "https://api.githubcopilot.com/mcp/",
"headers": {
"Authorization": "Bearer ${GITHUB_PERSONAL_ACCESS_TOKEN}"
}
}
}
}
```
For additional options like toolsets and read-only mode, see the [remote server documentation](../remote-server.md#optional-headers).
#### Local Docker
With Docker running, you can run the GitHub MCP server in a container:
Log in with OAuth instead of a token. On github.com the official image already includes the app credentials, so you provide none yourself — the server opens a browser login on first use and keeps the token in memory only. In Docker, publish a fixed callback port to loopback:
```json
{
"mcpServers": {
"github": {
"command": "docker",
"args": [
"run",
"-i",
"--rm",
"-p",
"127.0.0.1:8085:8085",
"-e",
"GITHUB_OAUTH_CALLBACK_PORT",
"ghcr.io/github/github-mcp-server"
],
"env": {
"GITHUB_OAUTH_CALLBACK_PORT": "8085"
}
}
}
}
```
See **[Local Server OAuth Login](../oauth-login.md)** for the native-binary flow (no fixed port), headless/device-code fallback, GitHub Enterprise, and bringing your own OAuth or GitHub App.
To authenticate with a Personal Access Token instead (it takes precedence over OAuth):
```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:
```bash
go build -o github-mcp-server ./cmd/github-mcp-server
```
Then configure (replace `/path/to/binary` with the actual path):
```json
{
"mcpServers": {
"github": {
"command": "/path/to/binary",
"args": ["stdio"],
"env": {
"GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_PERSONAL_ACCESS_TOKEN}"
}
}
}
}
```
## Verification
1. Restart Copilot CLI
2. Run `/mcp show` to list configured servers
3. Try: "List my GitHub repositories"
## Troubleshooting
### Local Server Issues
- **Docker errors**: Ensure Docker Desktop is running
- **Image pull failures**: Try `docker logout ghcr.io` then retry
### 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)
+143
View File
@@ -0,0 +1,143 @@
# Install GitHub MCP Server in Cursor
## Prerequisites
1. Cursor IDE installed (latest version)
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
## Remote Server Setup (Recommended)
[![Install MCP Server](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/en/install-mcp?name=github&config=eyJ1cmwiOiJodHRwczovL2FwaS5naXRodWJjb3BpbG90LmNvbS9tY3AvIiwiaGVhZGVycyI6eyJBdXRob3JpemF0aW9uIjoiQmVhcmVyIFlPVVJfR0lUSFVCX1BBVCJ9fQ%3D%3D)
Uses GitHub's hosted server at https://api.githubcopilot.com/mcp/. Requires Cursor v0.48.0+ for Streamable HTTP support. While Cursor supports OAuth for some MCP servers, the GitHub server currently requires a Personal Access Token.
### Install steps
1. Click the install button above and follow the flow, or go directly to your global MCP configuration file at `~/.cursor/mcp.json` and enter the code block below
2. In Tools & Integrations > MCP tools, click the pencil icon next to "github"
3. Replace `YOUR_GITHUB_PAT` with your actual [GitHub Personal Access Token](https://github.com/settings/tokens)
4. Save the file
5. Restart Cursor
### Streamable HTTP Configuration
```json
{
"mcpServers": {
"github": {
"url": "https://api.githubcopilot.com/mcp/",
"headers": {
"Authorization": "Bearer YOUR_GITHUB_PAT"
}
}
}
}
```
## Local Server Setup
[![Install MCP Server](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/en/install-mcp?name=github&config=eyJjb21tYW5kIjoiZG9ja2VyIHJ1biAtaSAtLXJtIC1lIEdJVEhVQl9QRVJTT05BTF9BQ0NFU1NfVE9LRU4gZ2hjci5pby9naXRodWIvZ2l0aHViLW1jcC1zZXJ2ZXIiLCJlbnYiOnsiR0lUSFVCX1BFUlNPTkFMX0FDQ0VTU19UT0tFTiI6IllPVVJfR0lUSFVCX1BBVCJ9fQ%3D%3D)
The local GitHub MCP server runs via Docker and requires Docker Desktop to be installed and running.
### Install steps
1. Click the install button above and follow the flow, or go directly to your global MCP configuration file at `~/.cursor/mcp.json` and enter the code block below
2. In Tools & Integrations > MCP tools, click the pencil icon next to "github"
3. Replace `YOUR_GITHUB_PAT` with your actual [GitHub Personal Access Token](https://github.com/settings/tokens)
4. Save the file
5. Restart Cursor
### Docker Configuration
Log in with OAuth instead of a token. On github.com the official image already includes the app credentials, so you provide none yourself — the server opens a browser login on first use and keeps the token in memory only. In Docker, publish a fixed callback port to loopback:
```json
{
"mcpServers": {
"github": {
"command": "docker",
"args": [
"run",
"-i",
"--rm",
"-p",
"127.0.0.1:8085:8085",
"-e",
"GITHUB_OAUTH_CALLBACK_PORT",
"ghcr.io/github/github-mcp-server"
],
"env": {
"GITHUB_OAUTH_CALLBACK_PORT": "8085"
}
}
}
}
```
See **[Local Server OAuth Login](../oauth-login.md)** for the native-binary flow (no fixed port), headless/device-code fallback, GitHub Enterprise, and bringing your own OAuth or GitHub App.
To authenticate with a Personal Access Token instead (it takes precedence over OAuth):
```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": "YOUR_GITHUB_PAT"
}
}
}
}
```
> **Important**: The npm package `@modelcontextprotocol/server-github` is no longer supported as of April 2025. Use the official Docker image `ghcr.io/github/github-mcp-server` instead.
## Configuration Files
- **Global (all projects)**: `~/.cursor/mcp.json`
- **Project-specific**: `.cursor/mcp.json` in project root
## Verify Installation
1. Restart Cursor completely
2. Check for green dot in Settings → Tools & Integrations → MCP Tools
3. In chat/composer, check "Available Tools"
4. Test with: "List my GitHub repositories"
## Troubleshooting
### Remote Server Issues
- **Streamable HTTP not working**: Ensure you're using Cursor v0.48.0 or later
- **Authentication failures**: Verify PAT has correct scopes
- **Connection errors**: Check firewall/proxy settings
### Local Server Issues
- **Docker errors**: Ensure Docker Desktop is running
- **Image pull failures**: Try `docker logout ghcr.io` then retry
- **Docker not found**: Install Docker Desktop and ensure it's running
### General Issues
- **MCP not loading**: Restart Cursor completely after configuration
- **Invalid JSON**: Validate that json format is correct
- **Tools not appearing**: Check server shows green dot in MCP settings
- **Check logs**: Look for MCP-related errors in Cursor logs
## Important Notes
- **Docker image**: `ghcr.io/github/github-mcp-server` (official and supported)
- **npm package**: `@modelcontextprotocol/server-github` (deprecated as of April 2025 - no longer functional)
- **Cursor specifics**: Supports both project and global configurations, uses `mcpServers` key
@@ -0,0 +1,200 @@
# Install GitHub MCP Server in Google Gemini CLI
## Prerequisites
1. Google Gemini CLI installed (see [official Gemini CLI documentation](https://github.com/google-gemini/gemini-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
<details>
<summary><b>Storing Your PAT Securely</b></summary>
<br>
For security, avoid hardcoding your token. Create or update `~/.gemini/.env` (where `~` is your home or project directory) with your PAT:
```bash
# ~/.gemini/.env
GITHUB_MCP_PAT=your_token_here
```
</details>
## GitHub MCP Server Configuration
MCP servers for Gemini CLI are configured in its settings JSON under an `mcpServers` key.
- **Global configuration**: `~/.gemini/settings.json` where `~` is your home directory
- **Project-specific**: `.gemini/settings.json` in your project directory
After securely storing your PAT, you can add the GitHub MCP server configuration to your settings file using one of the methods below. You may need to restart the Gemini CLI for changes to take effect.
> **Note:** For the most up-to-date configuration options, see the [main README.md](../../README.md).
### Method 1: Gemini Extension (Recommended)
The simplest way is to use GitHub's hosted MCP server via our gemini extension.
`gemini extensions install https://github.com/github/github-mcp-server`
> [!NOTE]
> You will still need to have a personal access token with the appropriate scopes called `GITHUB_MCP_PAT` in your environment.
### Method 2: Remote Server
You can also connect to the hosted MCP server directly. After securely storing your PAT, configure Gemini CLI with:
```json
// ~/.gemini/settings.json
{
"mcpServers": {
"github": {
"httpUrl": "https://api.githubcopilot.com/mcp/",
"headers": {
"Authorization": "Bearer $GITHUB_MCP_PAT"
}
}
}
}
```
### Method 3: Local Docker
With docker running, you can run the GitHub MCP server in a container.
Log in with OAuth instead of a token. On github.com the official image already includes the app credentials, so you provide none yourself — the server opens a browser login on first use and keeps the token in memory only. In Docker, publish a fixed callback port to loopback:
```json
// ~/.gemini/settings.json
{
"mcpServers": {
"github": {
"command": "docker",
"args": [
"run",
"-i",
"--rm",
"-p",
"127.0.0.1:8085:8085",
"-e",
"GITHUB_OAUTH_CALLBACK_PORT",
"ghcr.io/github/github-mcp-server"
],
"env": {
"GITHUB_OAUTH_CALLBACK_PORT": "8085"
}
}
}
}
```
See **[Local Server OAuth Login](../oauth-login.md)** for the native-binary flow (no fixed port), headless/device-code fallback, GitHub Enterprise, and bringing your own OAuth or GitHub App.
To authenticate with a Personal Access Token instead (it takes precedence over OAuth):
```json
// ~/.gemini/settings.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_MCP_PAT"
}
}
}
}
```
### Method 4: 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 Gemini CLI with:
```json
// ~/.gemini/settings.json
{
"mcpServers": {
"github": {
"command": "/path/to/binary",
"args": ["stdio"],
"env": {
"GITHUB_PERSONAL_ACCESS_TOKEN": "$GITHUB_MCP_PAT"
}
}
}
}
```
To log in with OAuth instead of a PAT (no token to create or store), omit `GITHUB_PERSONAL_ACCESS_TOKEN` — the native binary uses a random loopback callback port, so no extra configuration is needed. See **[Local Server OAuth Login](../oauth-login.md)**.
## Verification
To verify that the GitHub MCP server has been configured, start Gemini CLI in your terminal with `gemini`, then:
1. **Check MCP server status**:
```
/mcp list
```
```
Configured MCP servers:
🟢 github - Ready (96 tools, 2 prompts)
Tools:
- github__add_comment_to_pending_review
- github__add_issue_comment
- github__add_sub_issue
...
```
2. **Test with a prompt**
```
List my GitHub repositories
```
## Additional Configuration
You can find more MCP configuration options for Gemini CLI here: [MCP Configuration Structure](https://google-gemini.github.io/gemini-cli/docs/tools/mcp-server.html#configuration-structure). For example, bypassing tool confirmations or excluding specific tools.
## 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 ~/.gemini/settings.json | jq .
```
- **MCP connection issues**: Check logs for connection errors:
```bash
gemini --debug "test command"
```
## References
- Gemini CLI Docs > [MCP Configuration Structure](https://google-gemini.github.io/gemini-cli/docs/tools/mcp-server.html#configuration-structure)
@@ -0,0 +1,181 @@
# Install GitHub MCP Server in OpenCode
[OpenCode](https://opencode.ai) is a terminal-based AI coding agent that exposes MCP servers under the `mcp` key in `opencode.json` (or `opencode.jsonc`). For general setup information (prerequisites, Docker installation, security best practices), see the [Installation Guides README](./README.md).
## Prerequisites
1. OpenCode installed (`brew install sst/tap/opencode` or see [OpenCode install docs](https://opencode.ai/docs/))
2. [GitHub Personal Access Token](https://github.com/settings/personal-access-tokens/new) with appropriate scopes
3. For local installation: [Docker](https://www.docker.com/) installed and running
> [!IMPORTANT]
> The OpenCode docs note that the GitHub MCP server can add a lot of tokens to your context. Consider limiting toolsets — for example, by setting `X-MCP-Toolsets` on the remote server or `--toolsets` on the local server — to keep prompts within your model's context window. See the [Server Configuration Guide](../server-configuration.md) and the [main README's toolsets section](../../README.md#available-toolsets).
## Remote Server (Recommended)
Uses GitHub's hosted server at `https://api.githubcopilot.com/mcp/`. Edit your [OpenCode config](https://opencode.ai/docs/config/) (typically `~/.config/opencode/opencode.json`, or `opencode.json` in your project root) and add the following under `mcp`:
```json
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
"github": {
"type": "remote",
"url": "https://api.githubcopilot.com/mcp/",
"enabled": true,
"oauth": false,
"headers": {
"Authorization": "Bearer YOUR_GITHUB_PAT"
}
}
}
}
```
Replace `YOUR_GITHUB_PAT` with your [GitHub Personal Access Token](https://github.com/settings/tokens). The `oauth: false` setting disables OpenCode's automatic OAuth discovery and tells it to use the PAT in `Authorization` instead — without this, OpenCode may try the OAuth flow first.
### Using an environment variable for the PAT
OpenCode supports environment-variable interpolation in config values via `{env:VAR_NAME}`. To avoid putting your PAT directly in `opencode.json`:
```json
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
"github": {
"type": "remote",
"url": "https://api.githubcopilot.com/mcp/",
"enabled": true,
"oauth": false,
"headers": {
"Authorization": "Bearer {env:GITHUB_PERSONAL_ACCESS_TOKEN}"
}
}
}
}
```
Set `GITHUB_PERSONAL_ACCESS_TOKEN` in your shell environment before starting OpenCode.
## Local Server (Docker)
The local GitHub MCP server runs via Docker and requires Docker Desktop (or another Docker runtime) to be installed and running.
Log in with OAuth instead of a token. On github.com the official image already includes the app credentials, so you provide none yourself — the server opens a browser login on first use and keeps the token in memory only. In Docker, publish a fixed callback port to loopback:
```json
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
"github": {
"type": "local",
"command": [
"docker", "run", "-i", "--rm",
"-p", "127.0.0.1:8085:8085",
"-e", "GITHUB_OAUTH_CALLBACK_PORT",
"ghcr.io/github/github-mcp-server"
],
"enabled": true,
"environment": {
"GITHUB_OAUTH_CALLBACK_PORT": "8085"
}
}
}
}
```
See **[Local Server OAuth Login](../oauth-login.md)** for the native-binary flow (no fixed port), headless/device-code fallback, GitHub Enterprise, and bringing your own OAuth or GitHub App.
To authenticate with a Personal Access Token instead (it takes precedence over OAuth):
```json
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
"github": {
"type": "local",
"command": [
"docker", "run", "-i", "--rm",
"-e", "GITHUB_PERSONAL_ACCESS_TOKEN",
"ghcr.io/github/github-mcp-server"
],
"enabled": true,
"environment": {
"GITHUB_PERSONAL_ACCESS_TOKEN": "YOUR_GITHUB_PAT"
}
}
}
}
```
> [!IMPORTANT]
> OpenCode expects `command` as a **single array** combining the executable and its arguments (e.g. `["docker", "run", "-i", ...]`), and the env-var key is `environment` (not `env`). This differs from hosts like Zed and Cursor.
## Verify Installation
1. Restart OpenCode (or start a new session).
2. Check that the server is discovered:
```sh
opencode mcp list
```
3. Try a prompt that references the server by name to bias the model toward its tools:
```
Use the github tool to list my recently merged pull requests.
```
## Managing the Server
OpenCode exposes a few useful subcommands for MCP servers:
| Command | Purpose |
| --- | --- |
| `opencode mcp list` | List configured MCP servers and their auth/connection status. |
| `opencode mcp debug github` | Show auth status, test HTTP connectivity, and walk through OAuth discovery for the `github` server. |
| `opencode mcp auth github` | Trigger an OAuth flow manually (only relevant if `oauth` is not set to `false`). |
| `opencode mcp logout github` | Clear stored OAuth tokens for the server. |
## Disabling Tools Per-Agent
Because the GitHub MCP server can register a large number of tools, you may want to **disable them globally** and **re-enable them only for specific agents**. OpenCode uses the `<server-name>_*` glob pattern to match all tools from a server:
```json
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
"github": {
"type": "remote",
"url": "https://api.githubcopilot.com/mcp/",
"enabled": true,
"oauth": false,
"headers": { "Authorization": "Bearer {env:GITHUB_PERSONAL_ACCESS_TOKEN}" }
}
},
"tools": {
"github_*": false
},
"agent": {
"github-helper": {
"tools": { "github_*": true }
}
}
}
```
This pattern is recommended by the [OpenCode MCP docs](https://opencode.ai/docs/mcp-servers/) for servers with many tools.
## Troubleshooting
- **`401 Unauthorized` from the remote server**: confirm your PAT is valid and not expired. If you set `oauth: false`, OpenCode will not attempt an OAuth fallback — the `Authorization` header must be correct.
- **Server marked failed in `opencode mcp list`**: run `opencode mcp debug github` to see the exact connectivity and auth diagnostics.
- **Tools missing from prompts**: check that `enabled: true` is set on the server and that you have not disabled `github_*` in your `tools` block without re-enabling it for the current agent.
- **Context window exceeded**: the GitHub MCP server can register many tools. Use server-side toolset filtering (`X-MCP-Toolsets` header) to register only the toolsets you need.
- **Docker errors on the local server**: ensure Docker Desktop is running and the `ghcr.io/github/github-mcp-server` image has been pulled (`docker pull ghcr.io/github/github-mcp-server`).
## Important Notes
- **Configuration key**: OpenCode uses `mcp` (not `mcpServers` or `context_servers`).
- **Type discriminator**: every entry must include `"type": "local"` or `"type": "remote"`.
- **Command shape**: `command` is a single array combining the executable and its arguments.
- **Environment variable key**: `environment` (not `env`).
- **OAuth**: enabled by default for remote servers. Set `"oauth": false` when using PAT-in-`Authorization`, otherwise OpenCode may try OAuth first.
- **Env interpolation**: use `{env:VAR_NAME}` in string values to read from the shell environment instead of hard-coding secrets.
@@ -0,0 +1,357 @@
# Install GitHub MCP Server in Copilot IDEs
Quick setup guide for the GitHub MCP server in GitHub Copilot across different IDEs. For VS Code instructions, refer to the [VS Code install guide in the README](/README.md#installation-in-vs-code)
### Requirements:
- **GitHub Copilot License**: Any Copilot plan (Free, Pro, Pro+, Business, Enterprise) for Copilot access
- **GitHub Account**: Individual GitHub account (organization/enterprise membership optional) for GitHub MCP server access
- **MCP Servers in Copilot Policy**: Organizations assigning Copilot seats must enable this policy for all MCP access in Copilot for VS Code and Copilot Coding Agent all other Copilot IDEs will migrate to this policy in the coming months
- **Editor Preview Policy**: Organizations assigning Copilot seats must enable this policy for OAuth access while the Remote GitHub MCP Server is in public preview
> **Note:** All Copilot IDEs now support the remote GitHub MCP server. VS Code offers OAuth authentication, while Visual Studio, JetBrains IDEs, Xcode, and Eclipse currently use PAT authentication with OAuth support coming soon.
## Visual Studio
Requires Visual Studio 2022 version 17.14.9 or later.
### Remote Server (Recommended)
The remote GitHub MCP server is hosted by GitHub and provides automatic updates with no local setup required.
#### Configuration
1. Create an `.mcp.json` file in your solution or %USERPROFILE% directory.
2. Add this configuration:
```json
{
"servers": {
"github": {
"url": "https://api.githubcopilot.com/mcp/"
}
}
}
```
3. Save the file. Wait for CodeLens to update to offer a way to authenticate to the new server, activate that and pick the GitHub account to authenticate with.
4. In the GitHub Copilot Chat window, switch to Agent mode.
5. Activate the tool picker in the Chat window and enable one or more tools from the "github" MCP server.
### Local Server
For users who prefer to run the GitHub MCP server locally. Requires Docker installed and running.
#### Configuration
1. Create an `.mcp.json` file in your solution or %USERPROFILE% directory.
2. Add this configuration. Log in with OAuth instead of a token. On github.com the official image already includes the app credentials, so you provide none yourself — the server opens a browser login on first use and keeps the token in memory only. In Docker, publish a fixed callback port to loopback:
```json
{
"servers": {
"github": {
"type": "stdio",
"command": "docker",
"args": [
"run", "-i", "--rm", "-p", "127.0.0.1:8085:8085", "-e", "GITHUB_OAUTH_CALLBACK_PORT",
"ghcr.io/github/github-mcp-server"
],
"env": {
"GITHUB_OAUTH_CALLBACK_PORT": "8085"
}
}
}
}
```
See **[Local Server OAuth Login](../oauth-login.md)** for the native-binary flow (no fixed port), headless/device-code fallback, GitHub Enterprise, and bringing your own OAuth or GitHub App.
To authenticate with a Personal Access Token instead (it takes precedence over OAuth):
```json
{
"inputs": [
{
"id": "github_pat",
"description": "GitHub personal access token",
"type": "promptString",
"password": true
}
],
"servers": {
"github": {
"type": "stdio",
"command": "docker",
"args": [
"run", "-i", "--rm", "-e", "GITHUB_PERSONAL_ACCESS_TOKEN",
"ghcr.io/github/github-mcp-server"
],
"env": {
"GITHUB_PERSONAL_ACCESS_TOKEN": "${input:github_pat}"
}
}
}
}
```
3. Save the file. Wait for CodeLens to update to offer a way to provide user inputs, activate that and paste in a PAT you generate from https://github.com/settings/tokens.
4. In the GitHub Copilot Chat window, switch to Agent mode.
5. Activate the tool picker in the Chat window and enable one or more tools from the "github" MCP server.
**Documentation:** [Visual Studio MCP Guide](https://learn.microsoft.com/visualstudio/ide/mcp-servers)
---
## JetBrains IDEs
Agent mode and MCP support available in public preview across IntelliJ IDEA, PyCharm, WebStorm, and other JetBrains IDEs.
### Remote Server (Recommended)
The remote GitHub MCP server is hosted by GitHub and provides automatic updates with no local setup required.
> **Note**: OAuth authentication for the remote GitHub server is not yet supported in JetBrains IDEs. You must use a Personal Access Token (PAT).
#### Configuration Steps
1. Install/update the GitHub Copilot plugin
2. Click **GitHub Copilot icon in the status bar****Edit Settings****Model Context Protocol****Configure**
3. Add configuration:
```json
{
"servers": {
"github": {
"url": "https://api.githubcopilot.com/mcp/",
"requestInit": {
"headers": {
"Authorization": "Bearer YOUR_GITHUB_PAT"
}
}
}
}
}
```
4. Press `Ctrl + S` or `Command + S` to save, or close the `mcp.json` file. The configuration should take effect immediately and restart all the MCP servers defined. You can restart the IDE if needed.
### Local Server
For users who prefer to run the GitHub MCP server locally. Requires Docker installed and running.
#### Configuration
Log in with OAuth instead of a token. On github.com the official image already includes the app credentials, so you provide none yourself — the server opens a browser login on first use and keeps the token in memory only. In Docker, publish a fixed callback port to loopback:
```json
{
"servers": {
"github": {
"command": "docker",
"args": [
"run", "-i", "--rm",
"-p", "127.0.0.1:8085:8085",
"-e", "GITHUB_OAUTH_CALLBACK_PORT",
"ghcr.io/github/github-mcp-server"
],
"env": {
"GITHUB_OAUTH_CALLBACK_PORT": "8085"
}
}
}
}
```
See **[Local Server OAuth Login](../oauth-login.md)** for the native-binary flow (no fixed port), headless/device-code fallback, GitHub Enterprise, and bringing your own OAuth or GitHub App.
To authenticate with a Personal Access Token instead (it takes precedence over OAuth):
```json
{
"servers": {
"github": {
"command": "docker",
"args": [
"run", "-i", "--rm",
"-e", "GITHUB_PERSONAL_ACCESS_TOKEN",
"ghcr.io/github/github-mcp-server"
],
"env": {
"GITHUB_PERSONAL_ACCESS_TOKEN": "YOUR_GITHUB_PAT"
}
}
}
}
```
**Documentation:** [JetBrains Copilot Guide](https://plugins.jetbrains.com/plugin/17718-github-copilot)
---
## Xcode
Agent mode and MCP support now available in public preview for Xcode.
### Remote Server (Recommended)
The remote GitHub MCP server is hosted by GitHub and provides automatic updates with no local setup required.
> **Note**: OAuth authentication for the remote GitHub server is not yet supported in Xcode. You must use a Personal Access Token (PAT).
#### Configuration Steps
1. Install/update [GitHub Copilot for Xcode](https://github.com/github/CopilotForXcode)
2. Open **GitHub Copilot for Xcode app****Agent Mode****🛠️ Tool Picker** → **Edit Config**
3. Configure your MCP servers:
```json
{
"servers": {
"github": {
"url": "https://api.githubcopilot.com/mcp/",
"requestInit": {
"headers": {
"Authorization": "Bearer YOUR_GITHUB_PAT"
}
}
}
}
}
```
### Local Server
For users who prefer to run the GitHub MCP server locally. Requires Docker installed and running.
#### Configuration
Log in with OAuth instead of a token. On github.com the official image already includes the app credentials, so you provide none yourself — the server opens a browser login on first use and keeps the token in memory only. In Docker, publish a fixed callback port to loopback:
```json
{
"servers": {
"github": {
"command": "docker",
"args": [
"run", "-i", "--rm",
"-p", "127.0.0.1:8085:8085",
"-e", "GITHUB_OAUTH_CALLBACK_PORT",
"ghcr.io/github/github-mcp-server"
],
"env": {
"GITHUB_OAUTH_CALLBACK_PORT": "8085"
}
}
}
}
```
See **[Local Server OAuth Login](../oauth-login.md)** for the native-binary flow (no fixed port), headless/device-code fallback, GitHub Enterprise, and bringing your own OAuth or GitHub App.
To authenticate with a Personal Access Token instead (it takes precedence over OAuth):
```json
{
"servers": {
"github": {
"command": "docker",
"args": [
"run", "-i", "--rm",
"-e", "GITHUB_PERSONAL_ACCESS_TOKEN",
"ghcr.io/github/github-mcp-server"
],
"env": {
"GITHUB_PERSONAL_ACCESS_TOKEN": "YOUR_GITHUB_PAT"
}
}
}
}
```
**Documentation:** [Xcode Copilot Guide](https://devblogs.microsoft.com/xcode/github-copilot-exploring-agent-mode-and-mcp-support-in-public-preview-for-xcode/)
---
## Eclipse
MCP support available with Eclipse 2024-03+ and latest version of the GitHub Copilot plugin.
### Remote Server (Recommended)
The remote GitHub MCP server is hosted by GitHub and provides automatic updates with no local setup required.
> **Note**: OAuth authentication for the remote GitHub server is not yet supported in Eclipse. You must use a Personal Access Token (PAT).
#### Configuration Steps
1. Install GitHub Copilot extension from Eclipse Marketplace
2. Click the **GitHub Copilot icon****Edit Preferences****MCP** (under **GitHub Copilot**)
3. Add GitHub MCP server configuration:
```json
{
"servers": {
"github": {
"url": "https://api.githubcopilot.com/mcp/",
"requestInit": {
"headers": {
"Authorization": "Bearer YOUR_GITHUB_PAT"
}
}
}
}
}
```
4. Click the "Apply and Close" button in the preference dialog and the configuration will take effect automatically.
### Local Server
For users who prefer to run the GitHub MCP server locally. Requires Docker installed and running.
#### Configuration
Log in with OAuth instead of a token. On github.com the official image already includes the app credentials, so you provide none yourself — the server opens a browser login on first use and keeps the token in memory only. In Docker, publish a fixed callback port to loopback:
```json
{
"servers": {
"github": {
"command": "docker",
"args": [
"run", "-i", "--rm",
"-p", "127.0.0.1:8085:8085",
"-e", "GITHUB_OAUTH_CALLBACK_PORT",
"ghcr.io/github/github-mcp-server"
],
"env": {
"GITHUB_OAUTH_CALLBACK_PORT": "8085"
}
}
}
}
```
See **[Local Server OAuth Login](../oauth-login.md)** for the native-binary flow (no fixed port), headless/device-code fallback, GitHub Enterprise, and bringing your own OAuth or GitHub App.
To authenticate with a Personal Access Token instead (it takes precedence over OAuth):
```json
{
"servers": {
"github": {
"command": "docker",
"args": [
"run", "-i", "--rm",
"-e", "GITHUB_PERSONAL_ACCESS_TOKEN",
"ghcr.io/github/github-mcp-server"
],
"env": {
"GITHUB_PERSONAL_ACCESS_TOKEN": "YOUR_GITHUB_PAT"
}
}
}
}
```
**Documentation:** [Eclipse Copilot plugin](https://marketplace.eclipse.org/content/github-copilot)
---
## GitHub Personal Access Token
For PAT authentication, see our [Personal Access Token documentation](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens) for setup instructions.
---
## Usage
After setup:
1. Restart your IDE completely
2. Open Agent mode in Copilot Chat
3. Try: *"List recent issues in this repository"*
4. Copilot can now access GitHub data and perform repository operations
---
## Troubleshooting
- **Connection issues**: Verify GitHub PAT permissions and IDE version compatibility
- **Authentication errors**: Check if your organization has enabled the MCP policy for Copilot
- **Tools not appearing**: Restart IDE after configuration changes and check error logs
- **Local server issues**: Ensure Docker is running for Docker-based setups
@@ -0,0 +1,83 @@
# Install GitHub MCP Server in Roo Code
[Roo Code](https://github.com/RooCodeInc/Roo-Code) is an AI coding assistant that runs in VS Code-compatible editors (VS Code, Cursor, Windsurf, etc.). For general setup information (prerequisites, Docker installation, security best practices), see the [Installation Guides README](./README.md).
## Remote Server
### Step-by-step setup
1. Click the **Roo Code icon** in your editor's sidebar to open the Roo Code pane
2. Click the **gear icon** (⚙️) in the top navigation of the Roo Code pane, then click on **"MCP Servers"** icon on the left.
3. Scroll to the bottom and click **"Edit Global MCP"** (for all projects) or **"Edit Project MCP"** (for the current project only)
4. Add the configuration below to the opened file (`mcp_settings.json` or `.roo/mcp.json`)
5. Replace `YOUR_GITHUB_PAT` with your [GitHub Personal Access Token](https://github.com/settings/tokens)
6. Save the file — the server should connect automatically
```json
{
"mcpServers": {
"github": {
"type": "streamable-http",
"url": "https://api.githubcopilot.com/mcp/",
"headers": {
"Authorization": "Bearer YOUR_GITHUB_PAT"
}
}
}
}
```
> **Important:** The `type` must be `"streamable-http"` (with hyphen). Using `"http"` or omitting the type will fail.
To customize toolsets, add server-side headers like `X-MCP-Toolsets` or `X-MCP-Readonly` to the `headers` object — see [Server Configuration Guide](../server-configuration.md).
## Local Server (Docker)
Log in with OAuth instead of a token. On github.com the official image already includes the app credentials, so you provide none yourself — the server opens a browser login on first use and keeps the token in memory only. In Docker, publish a fixed callback port to loopback:
```json
{
"mcpServers": {
"github": {
"command": "docker",
"args": [
"run", "-i", "--rm",
"-p", "127.0.0.1:8085:8085",
"-e", "GITHUB_OAUTH_CALLBACK_PORT",
"ghcr.io/github/github-mcp-server"
],
"env": {
"GITHUB_OAUTH_CALLBACK_PORT": "8085"
}
}
}
}
```
See **[Local Server OAuth Login](../oauth-login.md)** for the native-binary flow (no fixed port), headless/device-code fallback, GitHub Enterprise, and bringing your own OAuth or GitHub App.
To authenticate with a Personal Access Token instead (replace `YOUR_GITHUB_PAT`; it takes precedence over OAuth):
```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": "YOUR_GITHUB_PAT"
}
}
}
}
```
## Troubleshooting
- **Connection failures**: Ensure `type` is `streamable-http`, not `http`
- **Authentication failures**: Verify PAT is prefixed with `Bearer ` in the `Authorization` header
- **Docker issues**: Ensure Docker Desktop is running
@@ -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"
}
}
}
}
```
@@ -0,0 +1,136 @@
# Install GitHub MCP Server in Windsurf
## Prerequisites
1. Windsurf IDE installed (latest version)
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
## Remote Server Setup (Recommended)
The remote GitHub MCP server is hosted by GitHub at `https://api.githubcopilot.com/mcp/` and supports Streamable HTTP protocol. Windsurf currently supports PAT authentication only.
### Streamable HTTP Configuration
Windsurf supports Streamable HTTP servers with a `serverUrl` field:
```json
{
"mcpServers": {
"github": {
"serverUrl": "https://api.githubcopilot.com/mcp/",
"headers": {
"Authorization": "Bearer YOUR_GITHUB_PAT"
}
}
}
}
```
## Local Server Setup
### Docker Installation (Required)
**Important**: The npm package `@modelcontextprotocol/server-github` is no longer supported as of April 2025. Use the official Docker image `ghcr.io/github/github-mcp-server` instead.
Log in with OAuth instead of a token. On github.com the official image already includes the app credentials, so you provide none yourself — the server opens a browser login on first use and keeps the token in memory only. In Docker, publish a fixed callback port to loopback:
```json
{
"mcpServers": {
"github": {
"command": "docker",
"args": [
"run",
"-i",
"--rm",
"-p",
"127.0.0.1:8085:8085",
"-e",
"GITHUB_OAUTH_CALLBACK_PORT",
"ghcr.io/github/github-mcp-server"
],
"env": {
"GITHUB_OAUTH_CALLBACK_PORT": "8085"
}
}
}
}
```
See **[Local Server OAuth Login](../oauth-login.md)** for the native-binary flow (no fixed port), headless/device-code fallback, GitHub Enterprise, and bringing your own OAuth or GitHub App.
To authenticate with a Personal Access Token instead (it takes precedence over OAuth):
```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": "YOUR_GITHUB_PAT"
}
}
}
}
```
## Installation Steps
### Via Plugin Store
1. Open Windsurf and navigate to Cascade
2. Click the **Plugins** icon or **hammer icon** (🔨)
3. Search for "GitHub MCP Server"
4. Click **Install** and enter your PAT when prompted
5. Click **Refresh** (🔄)
### Manual Configuration
1. Click the hammer icon (🔨) in Cascade
2. Click **Configure** to open `~/.codeium/windsurf/mcp_config.json`
3. Add your chosen configuration from above
4. Save the file
5. Click **Refresh** (🔄) in the MCP toolbar
## Configuration Details
- **File path**: `~/.codeium/windsurf/mcp_config.json`
- **Scope**: Global configuration only (no per-project support)
- **Format**: Must be valid JSON (use a linter to verify)
## Verification
After installation:
1. Look for "1 available MCP server" in the MCP toolbar
2. Click the hammer icon to see available GitHub tools
3. Test with: "List my GitHub repositories"
4. Check for green dot next to the server name
## Troubleshooting
### Remote Server Issues
- **Authentication failures**: Verify PAT has correct scopes and hasn't expired
- **Connection errors**: Check firewall/proxy settings for HTTPS connections
- **Streamable HTTP not working**: Ensure you're using the correct `serverUrl` field format
### Local Server Issues
- **Docker errors**: Ensure Docker Desktop is running
- **Image pull failures**: Try `docker logout ghcr.io` then retry
- **Docker not found**: Install Docker Desktop and ensure it's running
### General Issues
- **Invalid JSON**: Validate with [jsonlint.com](https://jsonlint.com)
- **Tools not appearing**: Restart Windsurf completely
- **Check logs**: `~/.codeium/windsurf/logs/`
## Important Notes
- **Official repository**: [github/github-mcp-server](https://github.com/github/github-mcp-server)
- **Remote server URL**: `https://api.githubcopilot.com/mcp/`
- **Docker image**: `ghcr.io/github/github-mcp-server` (official and supported)
- **npm package**: `@modelcontextprotocol/server-github` (deprecated as of April 2025 - no longer functional)
- **Windsurf limitations**: No environment variable interpolation, global config only
+47
View File
@@ -0,0 +1,47 @@
# Install GitHub MCP Server in Xcode
Xcode currently supports two built-in coding agents: **Codex** (powered by OpenAI) and **Claude Agent** (powered by Anthropic). Follow the standard installation guide for each agent, with one important difference: Xcode uses its own isolated configuration directories for each agent, separate from your global config.
> Configurations placed in these directories only affect agents when launched from Xcode. See [Apple's documentation](https://developer.apple.com/documentation/xcode/setting-up-coding-intelligence#Customize-the-Claude-Agent-and-Codex-environments) for more details.
## Configuration Directories
| Agent | Configuration Directory |
|-------|------------------------|
| Codex | `~/Library/Developer/Xcode/CodingAssistant/codex/` |
| Claude Agent | `~/Library/Developer/Xcode/CodingAssistant/ClaudeAgentConfig/` |
Place your MCP server configuration in the relevant directory above rather than the default location used by the standalone CLI.
## Setup Guides
- **[Codex](install-codex.md)** — configure `config.toml` inside `~/Library/Developer/Xcode/CodingAssistant/codex/`
- **[Claude Agent](install-claude.md#xcode-claude-agent)** — configure `.claude.json` inside `~/Library/Developer/Xcode/CodingAssistant/ClaudeAgentConfig/`
## macOS Path Note
Xcode runs with a minimal `PATH` that typically excludes common binary locations. If you are using a local STDIO server (e.g. Docker or a pre-built binary), use the **full path** to the command in your config. Run `which docker` (or `which github-mcp-server`) in Terminal to find the correct path on your system. Common locations:
| Installation | Typical path |
|---|---|
| Docker (Intel Mac) | `/usr/local/bin/docker` |
| Docker (Apple Silicon) | `/usr/local/bin/docker` |
| Homebrew (Intel Mac) | `/usr/local/bin/` |
| Homebrew (Apple Silicon) | `/opt/homebrew/bin/` |
> **Logging in with OAuth?** You can run the local server with no PAT — it opens a browser login on first use and keeps the token in memory only. With Docker this needs a fixed callback port published to loopback (`-p 127.0.0.1:8085:8085 -e GITHUB_OAUTH_CALLBACK_PORT` with `GITHUB_OAUTH_CALLBACK_PORT=8085`); a native binary uses a random loopback port and needs no extra configuration. See **[Local Server OAuth Login](../oauth-login.md)**.
## Troubleshooting
| Issue | Possible Cause | Fix |
|-------|----------------|-----|
| Tools not loading | Config placed in wrong directory | Ensure config is in the Xcode-specific path above, not `~/.codex/` or `~/.claude.json` |
| Command not found (STDIO) | Xcode's PATH excludes binary location | Use the full path (e.g. `/usr/local/bin/docker` or `/opt/homebrew/bin/docker`); run `which docker` in Terminal to confirm |
| Docker not found | Docker not running | Start Docker Desktop and restart Xcode |
| Authentication failed | Invalid or expired PAT | Regenerate PAT and update config |
## References
- [Apple Developer Documentation — Setting up coding intelligence](https://developer.apple.com/documentation/xcode/setting-up-coding-intelligence#Customize-the-Claude-Agent-and-Codex-environments)
- [Codex MCP documentation](https://developers.openai.com/codex/mcp)
- Main project README: [Advanced configuration options](../../README.md)
+128
View File
@@ -0,0 +1,128 @@
# Install GitHub MCP Server in Zed
[Zed](https://zed.dev) is a high-performance multiplayer code editor with native MCP support. Zed exposes MCP servers under the `context_servers` settings key. For general setup information (prerequisites, Docker installation, security best practices), see the [Installation Guides README](./README.md).
## Prerequisites
1. Zed installed (latest version — Zed v0.224.0+ recommended for the modern `agent.tool_permissions` settings shape)
2. [GitHub Personal Access Token](https://github.com/settings/personal-access-tokens/new) with appropriate scopes
3. For local installation: [Docker](https://www.docker.com/) installed and running
## Installation Methods
There are two ways to install the GitHub MCP server in Zed:
- **Option A — Zed Extension (easiest):** a community-maintained [GitHub MCP extension](https://zed.dev/extensions/mcp-server-github) is available in the Zed extension gallery. Install it from the Agent Panel's top-right menu → "View Server Extensions", or from the command palette via the `zed: extensions` action. After installation, Zed pops up a modal asking for your GitHub Personal Access Token.
- **Option B — Custom Server (recommended for the official remote endpoint):** add the configuration manually to `settings.json` to use either GitHub's hosted remote server or the official Docker image directly. The rest of this guide covers Option B.
## Remote Server (Recommended)
Uses GitHub's hosted server at `https://api.githubcopilot.com/mcp/`. Open your Zed [settings file](https://zed.dev/docs/configuring-zed.html#settings-files) (Command Palette → `zed: open settings`) and add the configuration below under `context_servers`.
```json
{
"context_servers": {
"github": {
"url": "https://api.githubcopilot.com/mcp/",
"headers": {
"Authorization": "Bearer YOUR_GITHUB_PAT"
}
}
}
}
```
Replace `YOUR_GITHUB_PAT` with your [GitHub Personal Access Token](https://github.com/settings/tokens). To customize toolsets, add server-side headers like `X-MCP-Toolsets` or `X-MCP-Readonly` to the `headers` object — see the [Server Configuration Guide](../server-configuration.md).
> [!NOTE]
> If you omit the `Authorization` header, Zed will attempt the standard MCP OAuth flow on first use. The GitHub MCP server does not currently advertise OAuth for non-Copilot hosts, so a Personal Access Token in the `Authorization` header is the supported path.
## Local Server (Docker)
The local GitHub MCP server runs via Docker and requires Docker Desktop (or another Docker runtime) to be installed and running.
Log in with OAuth instead of a token. On github.com the official image already includes the app credentials, so you provide none yourself — the server opens a browser login on first use and keeps the token in memory only. In Docker, publish a fixed callback port to loopback:
```json
{
"context_servers": {
"github": {
"command": "docker",
"args": [
"run", "-i", "--rm",
"-p", "127.0.0.1:8085:8085",
"-e", "GITHUB_OAUTH_CALLBACK_PORT",
"ghcr.io/github/github-mcp-server"
],
"env": {
"GITHUB_OAUTH_CALLBACK_PORT": "8085"
}
}
}
}
```
See **[Local Server OAuth Login](../oauth-login.md)** for the native-binary flow (no fixed port), headless/device-code fallback, GitHub Enterprise, and bringing your own OAuth or GitHub App.
To authenticate with a Personal Access Token instead (it takes precedence over OAuth):
```json
{
"context_servers": {
"github": {
"command": "docker",
"args": [
"run", "-i", "--rm",
"-e", "GITHUB_PERSONAL_ACCESS_TOKEN",
"ghcr.io/github/github-mcp-server"
],
"env": {
"GITHUB_PERSONAL_ACCESS_TOKEN": "YOUR_GITHUB_PAT"
}
}
}
}
```
> [!IMPORTANT]
> Zed expects `command` as a **string** plus a separate `args` array, not a single array combining both. This differs from hosts like OpenCode and Claude Desktop.
## Verify Installation
1. Open the Agent Panel and click into its Settings view (or run `agent: open settings`).
2. Find `github` in the context servers list. A green indicator dot with the tooltip "Server is active" confirms a working configuration. Other colors and tooltip messages indicate startup or auth errors.
3. Try a prompt that should invoke a tool — for example, `List my recent GitHub pull requests`. Zed will prompt for tool approval before the first call unless your `agent.tool_permissions.default` is set to `"allow"`.
## Tool Permissions (Optional)
Zed v0.224.0+ controls tool approval via `agent.tool_permissions`. Approve a specific GitHub MCP tool without per-call prompts by using the `mcp:<server>:<tool_name>` key format:
```json
{
"agent": {
"tool_permissions": {
"default": "confirm",
"rules": [
{ "tool": "mcp:github:list_pull_requests", "permission": "allow" },
{ "tool": "mcp:github:list_issues", "permission": "allow" }
]
}
}
}
```
See the [Zed tool permissions docs](https://zed.dev/docs/ai/tool-permissions.html) for the full schema.
## Troubleshooting
- **Server indicator stays red / "Server is not running"**: check the Agent Panel's settings view for the per-server error string. Most common cause is invalid JSON in `settings.json` — Zed surfaces JSON parse errors in the editor itself.
- **`401 Unauthorized`**: verify your PAT has not expired and includes the scopes for the tools you intend to call. The remote endpoint will reject requests with no `Authorization` header (no anonymous access).
- **Tools missing from prompts**: confirm the Agent profile in use has not disabled the server. If you're using a [custom profile](https://zed.dev/docs/ai/agent-panel.html#custom-profiles), make sure `enable_all_context_servers` is `true` or that `github` is explicitly listed.
- **Docker errors on the local server**: ensure Docker Desktop is running and the `ghcr.io/github/github-mcp-server` image has been pulled at least once. Try `docker pull ghcr.io/github/github-mcp-server` from a terminal.
## Important Notes
- **Configuration key**: Zed uses `context_servers` (not `mcpServers`).
- **Command shape**: `command` is a string + separate `args` array.
- **OAuth**: omitting `Authorization` triggers Zed's MCP OAuth flow, but the GitHub MCP server's PAT-based auth is the supported path today.
- **External agents**: MCP servers configured in `context_servers` are forwarded to [external agents](https://zed.dev/docs/ai/external-agents.html) via the Agent Client Protocol.
+266
View File
@@ -0,0 +1,266 @@
# Local Server OAuth Login (stdio)
The local (stdio) GitHub MCP Server can log you in with OAuth instead of a
Personal Access Token (PAT). On first use it walks you through GitHub's
authorization flow in your browser and keeps the resulting token **in memory
only** — nothing is written to disk.
Official released binaries and the `ghcr.io/github/github-mcp-server` image ship
with a registered GitHub OAuth application baked in, so on **github.com** you can
start the server with no token and no client ID at all. To target a different
host (GitHub Enterprise Server or `ghe.com`), or to use your own application,
pass `--oauth-client-id` (see [Bring your own app](#bring-your-own-app)).
> OAuth login applies to the **stdio** server only. The remote server and the
> `http` command have their own authentication; see
> [Remote Server](remote-server.md).
## Contents
- [How it works](#how-it-works)
- [Quick start](#quick-start)
- [Configuration reference](#configuration-reference)
- [Scope filtering](#scope-filtering)
- [Running in Docker](#running-in-docker)
- [Headless and device-code fallback](#headless-and-device-code-fallback)
- [URL elicitation and the security advisory](#url-elicitation-and-the-security-advisory)
- [Bring your own app](#bring-your-own-app)
- [GitHub Enterprise Server and ghe.com](#github-enterprise-server-and-ghecom)
- [Building from source with baked-in credentials](#building-from-source-with-baked-in-credentials)
## How it works
The server prefers the **authorization code flow with PKCE**: it starts a
loopback callback server on your machine, opens GitHub's authorization page, and
exchanges the returned code for a token. GitHub requires a client secret at the
token endpoint (for both OAuth Apps and GitHub Apps), so the exchange sends it
together with the PKCE verifier. Because this is a public, distributed client,
that secret is baked into the binary and is **not truly confidential** — PKCE is
what secures the flow: it binds the authorization code to this one login attempt,
so a code intercepted on the loopback redirect can't be redeemed anywhere else.
To present the authorization URL, the server uses the most secure channel your
MCP client offers, in order:
1. **Open your browser automatically** (native runs).
2. **URL elicitation** — the client prompts you with the link out of band, so the
URL never enters the model's context. Requires a client that supports MCP
elicitation (e.g. VS Code 1.101+).
3. **A message in the first tool response** — a last resort for clients without
elicitation. This includes a [security advisory](#url-elicitation-and-the-security-advisory).
If the authorization-code flow can't be used — for example, a container with no
published callback port — the server falls back to the
[device-code flow](#headless-and-device-code-fallback).
GitHub App tokens that expire are refreshed transparently using the refresh
token, so long-running sessions keep working without re-authorizing.
## Quick start
**Native binary (recommended).** Best experience: a random loopback port is
used and your browser opens automatically. On github.com with an official build,
no flags are needed:
```bash
github-mcp-server stdio
```
With your own application:
```bash
github-mcp-server stdio --oauth-client-id <YOUR_CLIENT_ID>
```
VS Code (`.vscode/mcp.json`), using your own app:
```json
{
"servers": {
"github": {
"command": "/path/to/github-mcp-server",
"args": ["stdio", "--oauth-client-id", "<YOUR_CLIENT_ID>"]
}
}
}
```
For Docker, see [Running in Docker](#running-in-docker) — containers need a fixed
callback port.
## Configuration reference
OAuth login is configured with these stdio flags (each has an environment
variable equivalent). Flags apply only to the `stdio` command.
| Flag | Environment variable | Description |
|------|----------------------|-------------|
| `--oauth-client-id` | `GITHUB_OAUTH_CLIENT_ID` | OAuth App or GitHub App client ID. Enables OAuth login when no token is set. Defaults to the baked-in app on github.com for official builds. |
| `--oauth-client-secret` | `GITHUB_OAUTH_CLIENT_SECRET` | Client secret, **if your app requires one**. For distributed clients this is a public, non-confidential credential. |
| `--oauth-scopes` | `GITHUB_OAUTH_SCOPES` | Comma-separated scopes to request. Also [filters tools](#scope-filtering) to those scopes. Defaults to the full supported set. |
| `--oauth-callback-port` | `GITHUB_OAUTH_CALLBACK_PORT` | Fixed local port for the callback server. Defaults to a random port; set a fixed port when mapping it through Docker. |
A static token still takes precedence: if `GITHUB_PERSONAL_ACCESS_TOKEN` is set,
the server uses it and skips OAuth entirely.
## Scope filtering
The scopes you request determine which tools are exposed. Requesting the full
supported set (the default) hides no tools. Narrowing `--oauth-scopes` both
narrows the token's grant **and** filters out tools that would need a scope you
didn't request, so the tool list reflects what the token can actually do.
For example, requesting only `repo,read:org` hides tools that require `gist`,
`workflow`, `notifications`, and so on.
## Running in Docker
A container can't reach a random loopback port on your host, so Docker OAuth
needs a **fixed** callback port that you publish into the container. Use port
**8085** to match the official app's registered callback URL.
```bash
docker run -i --rm \
-p 127.0.0.1:8085:8085 \
-e GITHUB_OAUTH_CALLBACK_PORT=8085 \
ghcr.io/github/github-mcp-server
```
VS Code (`.vscode/mcp.json`):
```json
{
"servers": {
"github": {
"command": "docker",
"args": [
"run", "-i", "--rm",
"-p", "127.0.0.1:8085:8085",
"-e", "GITHUB_OAUTH_CALLBACK_PORT",
"ghcr.io/github/github-mcp-server"
],
"env": { "GITHUB_OAUTH_CALLBACK_PORT": "8085" }
}
}
}
```
Because the container can't open your host browser, the authorization URL
arrives via [URL elicitation](#url-elicitation-and-the-security-advisory) or the
tool-response message. After you authorize, your browser hits
`localhost:8085`, which Docker forwards into the container's callback.
If you bring your own app for Docker, register its callback URL as exactly
`http://localhost:8085/callback`.
> **Two safety properties to be aware of with a fixed port:**
>
> - **Publish to loopback only** (`-p 127.0.0.1:8085:8085`, not `-p 8085:8085`).
> Inside a container the callback necessarily listens on all interfaces, so a
> plain publish would expose the authorization code to your network. The
> server logs a warning reminding you of this when it binds inside a container.
> - **A busy port is fatal, by design.** With a fixed port, if the server can't
> bind it (another process already holds it), it **stops with an error** rather
> than silently falling back to the device flow. A port you didn't get could
> belong to another user's process positioned to receive the redirect, so the
> server refuses to continue. Free the port or choose a different
> `--oauth-callback-port`.
## Headless and device-code fallback
When there's no usable browser or callback — a remote shell, CI, or a container
started without a published port — the server uses GitHub's **device-code
flow**. You'll get a short code and a verification URL to open on any device:
```
Visit https://github.com/login/device and enter the code WDJB-MJHT to authorize
the GitHub MCP Server.
```
The server polls GitHub until you finish authorizing, then continues. No
callback port is involved, so this works anywhere.
## URL elicitation and the security advisory
URL elicitation lets your MCP client present the authorization URL to you
directly, keeping it **out of the model's context** — the model never sees the
link or any code embedded in it. This is the most secure way to hand off the
authorization step.
If your client doesn't support elicitation, the server falls back to placing the
URL in a tool response and appends a short advisory:
> Note: your MCP client does not appear to support secure URL elicitation. For
> improved security, consider asking your agent, CLI, or IDE to add it (for
> example, by opening an issue).
If you see this, your authorization still works — but consider asking your client
vendor to add elicitation support.
## Bring your own app
You need your own application when targeting a non-github.com host, or when you'd
rather not use the baked-in app. Either application type works:
- **[Create an OAuth App](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app)** —
simplest to set up. Grants the scopes you request.
- **[Register a GitHub App](https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/registering-a-github-app)** —
finer-grained, per-resource permissions and short-lived tokens that refresh
automatically. Enable **Device Flow** in the app settings if you want the
[headless fallback](#headless-and-device-code-fallback).
When registering, set the authorization callback URL:
- **Native runs** use a random loopback port. For loopback redirects GitHub does
not require the callback port to match, so registering
`http://localhost/callback` is sufficient.
- **Docker / fixed port** must match exactly: register
`http://localhost:8085/callback` (or whichever port you publish).
Then pass the client ID (and secret, only if your app requires one):
```bash
github-mcp-server stdio \
--oauth-client-id <YOUR_CLIENT_ID> \
--oauth-client-secret <YOUR_CLIENT_SECRET>
```
## GitHub Enterprise Server and ghe.com
The baked-in app is registered on github.com only, so it is **not** used when you
set a custom host. GitHub Enterprise Server and `ghe.com` (Enterprise Cloud with
data residency) users must **bring their own app** registered on that host and
pass `--oauth-client-id`.
Set the host with `--gh-host` / `GITHUB_HOST`; the server derives the OAuth
authorization, token, and device endpoints from it, so login is directed at your
instance's authorization server rather than github.com:
```bash
github-mcp-server stdio \
--gh-host https://github.example.com \
--oauth-client-id <YOUR_CLIENT_ID>
```
- For GitHub Enterprise Server, prefix the host with `https://`.
- For `ghe.com`, use `https://YOURSUBDOMAIN.ghe.com`.
Register the app's callback URL on the same host (e.g.
`http://localhost/callback` for native runs, or `http://localhost:8085/callback`
for Docker).
## Building from source with baked-in credentials
Official builds embed the default OAuth client via linker flags at build time, so
they are not present in the source tree. To produce your own build with embedded
credentials, set them with `-ldflags`:
```bash
go build -ldflags "\
-X github.com/github/github-mcp-server/internal/buildinfo.OAuthClientID=<CLIENT_ID> \
-X github.com/github/github-mcp-server/internal/buildinfo.OAuthClientSecret=<CLIENT_SECRET>" \
./cmd/github-mcp-server
```
Without these, a source build simply has no baked-in app and expects
`--oauth-client-id` (or a PAT) at runtime.
+216
View File
@@ -0,0 +1,216 @@
# Policies & Governance for the GitHub MCP Server
Organizations and enterprises have several existing control mechanisms for the GitHub MCP server on GitHub.com:
- MCP servers in Copilot Policy
- Copilot Editor Preview Policy (temporary)
- OAuth App Access Policies
- GitHub App Installation
- Personal Access Token (PAT) policies
- SSO Enforcement
This document outlines how these policies apply to different deployment modes, authentication methods, and host applications while providing guidance for managing GitHub MCP Server access across your organization.
## How the GitHub MCP Server Works
The GitHub MCP Server provides access to GitHub resources and capabilities through a standardized protocol, with flexible deployment and authentication options tailored to different use cases. It supports two deployment modes, both built on the same underlying codebase.
### 1. Local GitHub MCP Server
* **Runs:** Locally alongside your IDE or application
* **Authentication & Controls:** Requires Personal Access Tokens (PATs). Users must generate and configure a PAT to connect. Managed via [PAT policies](https://docs.github.com/organizations/managing-programmatic-access-to-your-organization/setting-a-personal-access-token-policy-for-your-organization#restricting-access-by-personal-access-tokens).
* Can optionally use GitHub App installation tokens when embedded in a GitHub App-based tool (rare).
**Supported SKUs:** Can be used with GitHub Enterprise Server (GHES) and GitHub Enterprise Cloud (GHEC).
### 2. Remote GitHub MCP Server
* **Runs:** As a hosted service accessed over the internet
* **Authentication & Controls:** (determined by the chosen authentication method)
* **GitHub App Installation Tokens:** Uses a signed JWT to request installation access tokens (similar to the OAuth 2.0 client credentials flow) to operate as the application itself. Provides granular control via [installation](https://docs.github.com/apps/using-github-apps/installing-a-github-app-from-a-third-party#requirements-to-install-a-github-app), [permissions](https://docs.github.com/apps/creating-github-apps/registering-a-github-app/choosing-permissions-for-a-github-app) and [repository access controls](https://docs.github.com/apps/using-github-apps/reviewing-and-modifying-installed-github-apps#modifying-repository-access).
* **OAuth Authorization Code Flow:** Uses the standard OAuth 2.0 Authorization Code flow. Controlled via [OAuth App access policies](https://docs.github.com/organizations/managing-oauth-access-to-your-organizations-data/about-oauth-app-access-restrictions) for OAuth apps. For GitHub Apps that sign in ([are authorized by](https://docs.github.com/apps/using-github-apps/authorizing-github-apps)) a user, control access to your organization via [installation](https://docs.github.com/apps/using-github-apps/installing-a-github-app-from-a-third-party#requirements-to-install-a-github-app).
* **Personal Access Tokens (PATs):** Managed via [PAT policies](https://docs.github.com/organizations/managing-programmatic-access-to-your-organization/setting-a-personal-access-token-policy-for-your-organization#restricting-access-by-personal-access-tokens).
* **SSO enforcement:** Applies when using OAuth Apps, GitHub Apps, and PATs to access resources in organizations and enterprises with SSO enabled. Acts as an overlay control. Users must have a valid SSO session for your organization or enterprise when signing into the app or creating the token in order for the token to access your resources. Learn more in the [SSO documentation](https://docs.github.com/enterprise-cloud@latest/authentication/authenticating-with-single-sign-on/about-authentication-with-single-sign-on#about-oauth-apps-github-apps-and-sso).
**Supported Platforms:** Currently available only on GitHub Enterprise Cloud (GHEC). Remote hosting for GHES is not supported at this time.
> **Note:** This does not apply to the Local GitHub MCP Server, which uses PATs and does not rely on GitHub App installations.
#### Enterprise Install Considerations
- When using the Remote GitHub MCP Server, if authenticating with OAuth instead of PAT, each host application must have a registered GitHub App (or OAuth App) to authenticate on behalf of the user.
- Enterprises may choose to install these apps in multiple organizations (e.g., per team or department) to scope access narrowly, or at the enterprise level to centralize access control across all child organizations.
- Enterprise installation is only supported for GitHub Apps. OAuth Apps can only be installed on a per organization basis in multi-org enterprises.
### Security Principles for Both Modes
* **Authentication:** Required for all operations, no anonymous access
* **Authorization:** Access enforced by GitHub's native permission model. Users and apps cannot use an MCP server to access more resources than they could otherwise access normally via the API.
* **Communication:** All data transmitted over HTTPS with optional SSE for real-time updates
* **Rate Limiting:** Subject to GitHub API rate limits based on authentication method
* **Token Storage:** Tokens should be stored securely using platform-appropriate credential storage
* **Audit Trail:** All underlying API calls are logged in GitHub's audit log when available
For integration architecture and implementation details, see the [Host Integration Guide](https://github.com/github/github-mcp-server/blob/main/docs/host-integration.md).
## Where It's Used
The GitHub MCP server can be accessed in various environments (referred to as "host" applications):
* **First-party Hosts:** GitHub Copilot in VS Code, Visual Studio, JetBrains, Eclipse, and Xcode with integrated MCP support, as well as Copilot Coding Agent.
* **Third-party Hosts:** Editors outside the GitHub ecosystem, such as Claude, Cursor, Windsurf, and Cline, that support connecting to MCP servers, as well as AI chat applications like Claude Desktop and other AI assistants that connect to MCP servers to fetch GitHub context or execute write actions.
## What It Can Access
The MCP server accesses GitHub resources based on the permissions granted through the chosen authentication method (PAT, OAuth, or GitHub App). These may include:
* Repository contents (files, branches, commits)
* Issues and pull requests
* Organization and team metadata
* User profile information
* Actions workflow runs, logs, and statuses
* Security and vulnerability alerts (if explicitly granted)
Access is always constrained by GitHub's public API permission model and the authenticated user's privileges.
## Control Mechanisms
### 1. Copilot Editors (first-party) → MCP Servers in Copilot Policy
* **Policy:** MCP servers in Copilot
* **Location:** Enterprise/Org → Policies → Copilot
* **What it controls:** When disabled, **completely blocks all GitHub MCP Server access** (both remote and local) for affected Copilot editors. Currently applies to VS Code and Copilot Coding Agent, with more Copilot editors expected to migrate to this policy over time.
* **Impact when disabled:** Host applications governed by this policy cannot connect to the GitHub MCP Server through any authentication method (OAuth, PAT, or GitHub App).
* **What it does NOT affect:**
* MCP support in Copilot on IDEs that are still in public preview (Visual Studio, JetBrains, Xcode, Eclipse)
* Third-party IDE or host apps (like Claude, Cursor, Windsurf) not governed by GitHub's Copilot policies
* Community-authored MCP servers using GitHub's public APIs
> **Important:** This policy provides comprehensive control over GitHub MCP Server access in Copilot editors. When disabled, users in affected applications will not be able to use the GitHub MCP Server regardless of deployment mode (remote or local) or authentication method.
#### Temporary: Copilot Editor Preview Policy
* **Policy:** Editor Preview Features
* **Status:** Being phased out as editors migrate to the "MCP servers in Copilot" policy above, and once the Remote GitHub MCP server goes GA
* **What it controls:** When disabled, prevents remaining Copilot editors from using the Remote GitHub MCP Server through OAuth connections in all first-party and third-party host applications (does not affect local deployments or PAT authentication)
> **Note:** As Copilot editors migrate from the "Copilot Editor Preview" policy to the "MCP servers in Copilot" policy, the scope of control becomes more centralized, blocking both remote and local GitHub MCP Server access when disabled. Access in third-party hosts is governed separately by OAuth App, GitHub App, and PAT policies.
### 2. Third-Party Host Apps (e.g., Claude, Cursor, Windsurf) → OAuth App or GitHub App Controls
#### a. OAuth App Access Policies
* **Control Mechanism:** OAuth App access restrictions
* **Location:** Org → Settings → Third-party Access → OAuth app policy
* **How it works:**
* Organization admins must approve OAuth App requests before host apps can access organization data
* Only applies when the host registers an OAuth App AND the user connects via OAuth 2.0 flow
#### b. GitHub App Installation
* **Control Mechanism:** GitHub App installation and permissions
* **Location:** Org → Settings → Third-party Access → GitHub Apps
* **What it controls:** Organization admins must install the app, select repositories, and grant permissions before the app can access organization-owned data or resources through the Remote GitHub Server.
* **How it works:**
* Organization admins must install the app, specify repositories, and approve permissions
* Only applies when the host registers a GitHub App AND the user authenticates through that flow
> **Note:** The authentication methods available depend on what your host application supports. While PATs work with any remote MCP-compatible host, OAuth and GitHub App authentication are only available if the host has registered an app with GitHub. Check your host application's documentation or support for more info.
### 3. PAT Access from Any Host → PAT Restrictions
* **Types:** Fine-grained PATs (recommended) and Classic tokens (legacy)
* **Location:**
* User level: [Personal Settings → Developer Settings → Personal Access Tokens](https://docs.github.com/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens#fine-grained-personal-access-tokens)
* Enterprise/Organization level: [Enterprise/Organization → Settings → Personal Access Tokens](https://docs.github.com/organizations/managing-programmatic-access-to-your-organization/setting-a-personal-access-token-policy-for-your-organization) (to control PAT creation/access policies)
* **What it controls:** Applies to all host apps and both local & remote GitHub MCP servers when users authenticate via PAT.
* **How it works:** Access limited to the repositories and scopes selected on the token.
* **Limitations:** PATs do not adhere to OAuth App policies and GitHub App installation controls. They are user-scoped and not recommended for production automation.
* **Organization controls:**
* Classic PATs: Can be completely disabled organization-wide
* Fine-grained PATs: Cannot be disabled but require explicit approval for organization access
> **Recommendation:** We recommend using fine-grained PATs over classic tokens. Classic tokens have broader scopes and can be disabled in organization settings.
### 4. SSO Enforcement (overlay control)
* **Location:** Enterprise/Organization → SSO settings
* **What it controls:** OAuth tokens and PATs must map to a recent SSO login to access SSO-protected organization data.
* **How it works:** Applies to ALL host apps when using OAuth or PATs.
> **Exception:** Does NOT apply to GitHub App installation tokens (these are installation-scoped, not user-scoped)
## Current Limitations
While the GitHub MCP Server provides dynamic tooling and capabilities, the following enterprise governance features are not yet available:
### Single Enterprise/Organization-Level Toggle
GitHub does not provide a single toggle that blocks all GitHub MCP server traffic for every user. Admins can achieve equivalent coverage by combining the controls shown here:
* **First-party Copilot Editors (GitHub Copilot in VS Code, Visual Studio, JetBrains, Eclipse):**
* Disable the "MCP servers in Copilot" policy for comprehensive control
* Or disable the Editor Preview Features policy (for editors still using the legacy policy)
* **Third-party Host Applications:**
* Configure OAuth app restrictions
* Manage GitHub App installations
* **PAT Access in All Host Applications:**
* Implement fine-grained PAT policies (applies to both remote and local deployments)
### MCP-Specific Audit Logging
At present, MCP traffic appears in standard GitHub audit logs as normal API calls. Purpose-built logging for MCP is on the roadmap, but the following views are not yet available:
* Real-time list of active MCP connections
* Dashboards showing granular MCP usage data, like tools or host apps
* Granular, action-by-action audit logs
Until those arrive, teams can continue to monitor MCP activity through existing API log entries and OAuth/GitHub App events.
## Security Best Practices
### For Organizations
**GitHub App Management**
* Review [GitHub App installations](https://docs.github.com/apps/using-github-apps/reviewing-and-modifying-installed-github-apps) regularly
* Audit permissions and repository access
* Monitor installation events in audit logs
* Document approved GitHub Apps and their business purposes
**OAuth App Governance**
* Manage [OAuth App access policies](https://docs.github.com/organizations/managing-oauth-access-to-your-organizations-data/about-oauth-app-access-restrictions)
* Establish review processes for approved applications
* Monitor which third-party applications are requesting access
* Maintain an allowlist of approved OAuth applications
**Token Management**
* Mandate fine-grained Personal Access Tokens over classic tokens
* Establish token expiration policies (90 days maximum recommended)
* Implement automated token rotation reminders
* Review and enforce [PAT restrictions](https://docs.github.com/organizations/managing-programmatic-access-to-your-organization/setting-a-personal-access-token-policy-for-your-organization) at the appropriate level
### For Developers and Users
**Authentication Security**
* Prioritize OAuth 2.0 flows over long-lived tokens
* Prefer fine-grained PATs to PATs (Classic)
* Store tokens securely using platform-appropriate credential management
* Store credentials in secret management systems, not source code
**Scope Minimization**
* Request only the minimum required scopes for your use case
* Regularly review and revoke unused token permissions
* Use repository-specific access instead of organization-wide access
* Document why each permission is needed for your integration
## Resources
**MCP:**
* [Model Context Protocol Specification](https://modelcontextprotocol.io/specification/2025-03-26)
* [Model Context Protocol Authorization](https://modelcontextprotocol.io/specification/draft/basic/authorization)
**GitHub Governance & Controls:**
* [Managing OAuth App Access](https://docs.github.com/organizations/managing-oauth-access-to-your-organizations-data/about-oauth-app-access-restrictions)
* [GitHub App Permissions](https://docs.github.com/apps/creating-github-apps/registering-a-github-app/choosing-permissions-for-a-github-app)
* [Updating permissions for a GitHub App](https://docs.github.com/apps/using-github-apps/approving-updated-permissions-for-a-github-app)
* [PAT Policies](https://docs.github.com/organizations/managing-programmatic-access-to-your-organization/setting-a-personal-access-token-policy-for-your-organization)
* [Fine-grained PATs](https://docs.github.com/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens#fine-grained-personal-access-tokens)
* [Setting a PAT policy for your organization](https://docs.github.com/organizations/managing-oauth-access-to-your-organizations-data/about-oauth-app-access-restrictions)
---
**Questions or Feedback?**
Open an [issue in the github-mcp-server repository](https://github.com/github/github-mcp-server/issues) with the label "policies & governance" attached.
This document reflects GitHub MCP Server policies as of July 2025. Policies and capabilities continue to evolve based on customer feedback and security best practices.
+145
View File
@@ -0,0 +1,145 @@
# Remote GitHub MCP Server 🚀
[![Install in VS Code](https://img.shields.io/badge/VS_Code-Install_Server-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2F%22%7D) [![Install in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install_Server-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2F%22%7D&quality=insiders)
Easily connect to the GitHub MCP Server using the hosted version no local setup or runtime required.
**URL:** https://api.githubcopilot.com/mcp/
## About
The remote GitHub MCP server is built using this repository as a library, and binding it into GitHub server infrastructure with an internal repository. You can open issues and propose changes in this repository, and we regularly update the remote server to include the latest version of this code.
The remote server has [additional tools](#toolsets-only-available-in-the-remote-mcp-server) that are not available in the local MCP server, such as the `create_pull_request_with_copilot` tool for invoking Copilot coding agent.
## Remote MCP Toolsets
Below is a table of available toolsets for the remote GitHub MCP Server. Each toolset is provided as a distinct URL so you can mix and match to create the perfect combination of tools for your use-case. Add `/readonly` to the end of any URL to restrict the tools in the toolset to only those that enable read access. We also provide the option to use [headers](#headers) instead.
<!-- START AUTOMATED TOOLSETS -->
| Name | Description | API URL | 1-Click Install (VS Code) | Read-only Link | 1-Click Read-only Install (VS Code) |
| ---- | ----------- | ------- | ------------------------- | -------------- | ----------------------------------- |
| <picture><source media="(prefers-color-scheme: dark)" srcset="../pkg/octicons/icons/apps-dark.png"><source media="(prefers-color-scheme: light)" srcset="../pkg/octicons/icons/apps-light.png"><img src="../pkg/octicons/icons/apps-light.png" width="20" height="20" alt="apps"></picture><br>`default` | Default toolset | https://api.githubcopilot.com/mcp/ | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2F%22%7D) | [read-only](https://api.githubcopilot.com/mcp/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Freadonly%22%7D) |
| <picture><source media="(prefers-color-scheme: dark)" srcset="../pkg/octicons/icons/apps-dark.png"><source media="(prefers-color-scheme: light)" srcset="../pkg/octicons/icons/apps-light.png"><img src="../pkg/octicons/icons/apps-light.png" width="20" height="20" alt="apps"></picture><br>`all` | All available GitHub MCP tools | https://api.githubcopilot.com/mcp/x/all | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-all&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fall%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/all/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-all&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fall%2Freadonly%22%7D) |
| <picture><source media="(prefers-color-scheme: dark)" srcset="../pkg/octicons/icons/workflow-dark.png"><source media="(prefers-color-scheme: light)" srcset="../pkg/octicons/icons/workflow-light.png"><img src="../pkg/octicons/icons/workflow-light.png" width="20" height="20" alt="workflow"></picture><br>`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) |
| <picture><source media="(prefers-color-scheme: dark)" srcset="../pkg/octicons/icons/code-square-dark.png"><source media="(prefers-color-scheme: light)" srcset="../pkg/octicons/icons/code-square-light.png"><img src="../pkg/octicons/icons/code-square-light.png" width="20" height="20" alt="code-square"></picture><br>`code_quality` | GitHub Code Quality related tools | https://api.githubcopilot.com/mcp/x/code_quality | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_quality&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcode_quality%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/code_quality/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_quality&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcode_quality%2Freadonly%22%7D) |
| <picture><source media="(prefers-color-scheme: dark)" srcset="../pkg/octicons/icons/codescan-dark.png"><source media="(prefers-color-scheme: light)" srcset="../pkg/octicons/icons/codescan-light.png"><img src="../pkg/octicons/icons/codescan-light.png" width="20" height="20" alt="codescan"></picture><br>`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) |
| <picture><source media="(prefers-color-scheme: dark)" srcset="../pkg/octicons/icons/copilot-dark.png"><source media="(prefers-color-scheme: light)" srcset="../pkg/octicons/icons/copilot-light.png"><img src="../pkg/octicons/icons/copilot-light.png" width="20" height="20" alt="copilot"></picture><br>`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) |
| <picture><source media="(prefers-color-scheme: dark)" srcset="../pkg/octicons/icons/dependabot-dark.png"><source media="(prefers-color-scheme: light)" srcset="../pkg/octicons/icons/dependabot-light.png"><img src="../pkg/octicons/icons/dependabot-light.png" width="20" height="20" alt="dependabot"></picture><br>`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) |
| <picture><source media="(prefers-color-scheme: dark)" srcset="../pkg/octicons/icons/comment-discussion-dark.png"><source media="(prefers-color-scheme: light)" srcset="../pkg/octicons/icons/comment-discussion-light.png"><img src="../pkg/octicons/icons/comment-discussion-light.png" width="20" height="20" alt="comment-discussion"></picture><br>`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) |
| <picture><source media="(prefers-color-scheme: dark)" srcset="../pkg/octicons/icons/logo-gist-dark.png"><source media="(prefers-color-scheme: light)" srcset="../pkg/octicons/icons/logo-gist-light.png"><img src="../pkg/octicons/icons/logo-gist-light.png" width="20" height="20" alt="logo-gist"></picture><br>`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) |
| <picture><source media="(prefers-color-scheme: dark)" srcset="../pkg/octicons/icons/git-branch-dark.png"><source media="(prefers-color-scheme: light)" srcset="../pkg/octicons/icons/git-branch-light.png"><img src="../pkg/octicons/icons/git-branch-light.png" width="20" height="20" alt="git-branch"></picture><br>`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) |
| <picture><source media="(prefers-color-scheme: dark)" srcset="../pkg/octicons/icons/issue-opened-dark.png"><source media="(prefers-color-scheme: light)" srcset="../pkg/octicons/icons/issue-opened-light.png"><img src="../pkg/octicons/icons/issue-opened-light.png" width="20" height="20" alt="issue-opened"></picture><br>`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) |
| <picture><source media="(prefers-color-scheme: dark)" srcset="../pkg/octicons/icons/tag-dark.png"><source media="(prefers-color-scheme: light)" srcset="../pkg/octicons/icons/tag-light.png"><img src="../pkg/octicons/icons/tag-light.png" width="20" height="20" alt="tag"></picture><br>`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) |
| <picture><source media="(prefers-color-scheme: dark)" srcset="../pkg/octicons/icons/bell-dark.png"><source media="(prefers-color-scheme: light)" srcset="../pkg/octicons/icons/bell-light.png"><img src="../pkg/octicons/icons/bell-light.png" width="20" height="20" alt="bell"></picture><br>`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) |
| <picture><source media="(prefers-color-scheme: dark)" srcset="../pkg/octicons/icons/organization-dark.png"><source media="(prefers-color-scheme: light)" srcset="../pkg/octicons/icons/organization-light.png"><img src="../pkg/octicons/icons/organization-light.png" width="20" height="20" alt="organization"></picture><br>`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) |
| <picture><source media="(prefers-color-scheme: dark)" srcset="../pkg/octicons/icons/project-dark.png"><source media="(prefers-color-scheme: light)" srcset="../pkg/octicons/icons/project-light.png"><img src="../pkg/octicons/icons/project-light.png" width="20" height="20" alt="project"></picture><br>`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) |
| <picture><source media="(prefers-color-scheme: dark)" srcset="../pkg/octicons/icons/git-pull-request-dark.png"><source media="(prefers-color-scheme: light)" srcset="../pkg/octicons/icons/git-pull-request-light.png"><img src="../pkg/octicons/icons/git-pull-request-light.png" width="20" height="20" alt="git-pull-request"></picture><br>`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) |
| <picture><source media="(prefers-color-scheme: dark)" srcset="../pkg/octicons/icons/repo-dark.png"><source media="(prefers-color-scheme: light)" srcset="../pkg/octicons/icons/repo-light.png"><img src="../pkg/octicons/icons/repo-light.png" width="20" height="20" alt="repo"></picture><br>`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) |
| <picture><source media="(prefers-color-scheme: dark)" srcset="../pkg/octicons/icons/shield-lock-dark.png"><source media="(prefers-color-scheme: light)" srcset="../pkg/octicons/icons/shield-lock-light.png"><img src="../pkg/octicons/icons/shield-lock-light.png" width="20" height="20" alt="shield-lock"></picture><br>`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) |
| <picture><source media="(prefers-color-scheme: dark)" srcset="../pkg/octicons/icons/shield-dark.png"><source media="(prefers-color-scheme: light)" srcset="../pkg/octicons/icons/shield-light.png"><img src="../pkg/octicons/icons/shield-light.png" width="20" height="20" alt="shield"></picture><br>`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) |
| <picture><source media="(prefers-color-scheme: dark)" srcset="../pkg/octicons/icons/star-dark.png"><source media="(prefers-color-scheme: light)" srcset="../pkg/octicons/icons/star-light.png"><img src="../pkg/octicons/icons/star-light.png" width="20" height="20" alt="star"></picture><br>`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) |
| <picture><source media="(prefers-color-scheme: dark)" srcset="../pkg/octicons/icons/people-dark.png"><source media="(prefers-color-scheme: light)" srcset="../pkg/octicons/icons/people-light.png"><img src="../pkg/octicons/icons/people-light.png" width="20" height="20" alt="people"></picture><br>`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) |
<!-- END AUTOMATED TOOLSETS -->
### Additional _Remote_ Server Toolsets
These toolsets are only available in the remote GitHub MCP Server and are not included in the local MCP server.
<!-- START AUTOMATED REMOTE TOOLSETS -->
| Name | Description | API URL | 1-Click Install (VS Code) | Read-only Link | 1-Click Read-only Install (VS Code) |
| ---- | ----------- | ------- | ------------------------- | -------------- | ----------------------------------- |
| <picture><source media="(prefers-color-scheme: dark)" srcset="../pkg/octicons/icons/copilot-dark.png"><source media="(prefers-color-scheme: light)" srcset="../pkg/octicons/icons/copilot-light.png"><img src="../pkg/octicons/icons/copilot-light.png" width="20" height="20" alt="copilot"></picture><br>`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) |
| <picture><source media="(prefers-color-scheme: dark)" srcset="../pkg/octicons/icons/book-dark.png"><source media="(prefers-color-scheme: light)" srcset="../pkg/octicons/icons/book-light.png"><img src="../pkg/octicons/icons/book-light.png" width="20" height="20" alt="book"></picture><br>`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) |
<!-- END AUTOMATED REMOTE TOOLSETS -->
### Optional Headers
The Remote GitHub MCP server has optional headers equivalent to the Local server env vars or flags:
- `X-MCP-Toolsets`: Comma-separated list of toolsets to enable. E.g. "repos,issues".
- Equivalent to `GITHUB_TOOLSETS` env var or `--toolsets` flag for Local server.
- If the list is empty, default toolsets will be used. Invalid or unknown toolsets are silently ignored without error and will not prevent the server from starting. Whitespace is ignored.
- `X-MCP-Tools`: Comma-separated list of tools to enable. E.g. "get_file_contents,issue_read,pull_request_read".
- Equivalent to `GITHUB_TOOLS` env var or `--tools` flag for Local server.
- Invalid tools will throw an error and prevent the server from starting. Whitespace is ignored.
- `X-MCP-Readonly`: Enables only "read" tools.
- Equivalent to `GITHUB_READ_ONLY` 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-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.
Example:
```json
{
"type": "http",
"url": "https://api.githubcopilot.com/mcp/",
"headers": {
"X-MCP-Toolsets": "repos,issues",
"X-MCP-Readonly": "true",
"X-MCP-Lockdown": "false"
}
}
```
### 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. Path modifiers like `/readonly` and `/insiders` can be combined with the `X-MCP-Insiders` or `X-MCP-Readonly` headers.
Example:
```json
{
"type": "http",
"url": "https://api.githubcopilot.com/mcp/x/issues/readonly"
}
```
+103
View File
@@ -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)
+479
View File
@@ -0,0 +1,479 @@
# Server Configuration Guide
This guide helps you choose the right configuration for your use case and shows you how to apply it. For the complete reference of available toolsets and tools, see the [README](../README.md#tool-configuration).
## Quick Reference
We currently support the following ways in which the GitHub MCP Server can be configured:
| Configuration | Remote Server | Local Server |
|---------------|---------------|--------------|
| Toolsets | `X-MCP-Toolsets` header or `/x/{toolset}` URL | `--toolsets` flag or `GITHUB_TOOLSETS` env var |
| Individual Tools | `X-MCP-Tools` header | `--tools` flag or `GITHUB_TOOLS` env var |
| Exclude Tools | `X-MCP-Exclude-Tools` header | `--exclude-tools` flag or `GITHUB_EXCLUDE_TOOLS` env var |
| Read-Only Mode | `X-MCP-Readonly` header or `/readonly` URL | `--read-only` flag or `GITHUB_READ_ONLY` env var |
| Lockdown Mode | `X-MCP-Lockdown` header | `--lockdown-mode` flag or `GITHUB_LOCKDOWN_MODE` env var |
| Insiders Mode | `X-MCP-Insiders` header or `/insiders` URL | `--insiders` flag or `GITHUB_INSIDERS` env var |
| Feature Flags | `X-MCP-Features` header | `--features` flag |
| Scope Filtering | Always enabled | Always enabled |
| Server Name/Title | Not available | `GITHUB_MCP_SERVER_NAME` / `GITHUB_MCP_SERVER_TITLE` env vars or `github-mcp-server-config.json` |
> **Default behavior:** If you don't specify any configuration, the server uses the **default toolsets**: `context`, `issues`, `pull_requests`, `repos`, `users`.
---
## How Configuration Works
All configuration options are **composable**: you can combine toolsets, individual tools, excluded tools, read-only mode and lockdown mode in any way that suits your workflow.
Note: **read-only** mode acts as a strict security filter that takes precedence over any other configuration, by disabling write tools even when explicitly requested.
Note: **excluded tools** takes precedence over toolsets and individual tools — listed tools are always excluded, even if their toolset is enabled or they are explicitly added via `--tools` / `X-MCP-Tools`.
---
## Configuration Examples
The examples below use VS Code configuration format to illustrate the concepts. If you're using a different MCP host (Cursor, Claude Desktop, JetBrains, etc.), your configuration might need to look slightly different. See [installation guides](./installation-guides) for host-specific setup.
### Enabling Specific Tools
**Best for:** users who know exactly what they need and want to optimize context usage by loading only the tools they will use.
**Example:**
<table>
<tr><th>Remote Server</th><th>Local Server</th></tr>
<tr valign="top">
<td>
```json
{
"type": "http",
"url": "https://api.githubcopilot.com/mcp/",
"headers": {
"X-MCP-Tools": "get_file_contents,get_me,pull_request_read"
}
}
```
</td>
<td>
```json
{
"type": "stdio",
"command": "go",
"args": [
"run",
"./cmd/github-mcp-server",
"stdio",
"--tools=get_file_contents,get_me,pull_request_read"
],
"env": {
"GITHUB_PERSONAL_ACCESS_TOKEN": "${input:github_token}"
}
}
```
</td>
</tr>
</table>
---
### Enabling Specific Toolsets
**Best for:** Users who want to enable multiple related toolsets.
<table>
<tr><th>Remote Server</th><th>Local Server</th></tr>
<tr valign="top">
<td>
```json
{
"type": "http",
"url": "https://api.githubcopilot.com/mcp/",
"headers": {
"X-MCP-Toolsets": "issues,pull_requests"
}
}
```
</td>
<td>
```json
{
"type": "stdio",
"command": "go",
"args": [
"run",
"./cmd/github-mcp-server",
"stdio",
"--toolsets=issues,pull_requests"
],
"env": {
"GITHUB_PERSONAL_ACCESS_TOKEN": "${input:github_token}"
}
}
```
</td>
</tr>
</table>
---
### Enabling Toolsets + Tools
**Best for:** Users who want broad functionality from some areas, plus specific tools from others.
Enable entire toolsets, then add individual tools from toolsets you don't want fully enabled.
<table>
<tr><th>Remote Server</th><th>Local Server</th></tr>
<tr valign="top">
<td>
```json
{
"type": "http",
"url": "https://api.githubcopilot.com/mcp/",
"headers": {
"X-MCP-Toolsets": "repos,issues",
"X-MCP-Tools": "get_gist,pull_request_read"
}
}
```
</td>
<td>
```json
{
"type": "stdio",
"command": "go",
"args": [
"run",
"./cmd/github-mcp-server",
"stdio",
"--toolsets=repos,issues",
"--tools=get_gist,pull_request_read"
],
"env": {
"GITHUB_PERSONAL_ACCESS_TOKEN": "${input:github_token}"
}
}
```
</td>
</tr>
</table>
**Result:** All repository and issue tools, plus just the gist tools you need.
---
### Excluding Specific Tools
**Best for:** Users who want to enable a broad toolset but need to exclude specific tools for security, compliance, or to prevent undesired behavior.
Listed tools are removed regardless of any other configuration — even if their toolset is enabled or they are individually added.
<table>
<tr><th>Remote Server</th><th>Local Server</th></tr>
<tr valign="top">
<td>
```json
{
"type": "http",
"url": "https://api.githubcopilot.com/mcp/",
"headers": {
"X-MCP-Toolsets": "pull_requests",
"X-MCP-Exclude-Tools": "create_pull_request,merge_pull_request"
}
}
```
</td>
<td>
```json
{
"type": "stdio",
"command": "go",
"args": [
"run",
"./cmd/github-mcp-server",
"stdio",
"--toolsets=pull_requests",
"--exclude-tools=create_pull_request,merge_pull_request"
],
"env": {
"GITHUB_PERSONAL_ACCESS_TOKEN": "${input:github_token}"
}
}
```
</td>
</tr>
</table>
**Result:** All pull request tools except `create_pull_request` and `merge_pull_request` — the user gets read and review tools only.
---
### Read-Only Mode
**Best for:** Security conscious users who want to ensure the server won't allow operations that modify issues, pull requests, repositories etc.
When active, this mode will disable all tools that are not read-only even if they were requested.
**Example:**
<table>
<tr><th>Remote Server</th><th>Local Server</th></tr>
<tr valign="top">
<td>
**Option A: Header**
```json
{
"type": "http",
"url": "https://api.githubcopilot.com/mcp/",
"headers": {
"X-MCP-Toolsets": "issues,repos,pull_requests",
"X-MCP-Readonly": "true"
}
}
```
**Option B: URL path**
```json
{
"type": "http",
"url": "https://api.githubcopilot.com/mcp/x/all/readonly"
}
```
</td>
<td>
```json
{
"type": "stdio",
"command": "go",
"args": [
"run",
"./cmd/github-mcp-server",
"stdio",
"--toolsets=issues,repos,pull_requests",
"--read-only"
],
"env": {
"GITHUB_PERSONAL_ACCESS_TOKEN": "${input:github_token}"
}
}
```
</td>
</tr>
</table>
> Even if `issues` toolset contains `create_issue`, it will be excluded in read-only mode.
---
### Lockdown Mode
**Best for:** Public repositories where you want to limit content from users without push access.
Lockdown mode ensures the server only surfaces content in public repositories from users with push access to that repository. Private repositories are unaffected, and collaborators retain full access to their own content.
**Example:**
<table>
<tr><th>Remote Server</th><th>Local Server</th></tr>
<tr valign="top">
<td>
```json
{
"type": "http",
"url": "https://api.githubcopilot.com/mcp/",
"headers": {
"X-MCP-Lockdown": "true"
}
}
```
</td>
<td>
```json
{
"type": "stdio",
"command": "go",
"args": [
"run",
"./cmd/github-mcp-server",
"stdio",
"--lockdown-mode"
],
"env": {
"GITHUB_PERSONAL_ACCESS_TOKEN": "${input:github_token}"
}
}
```
</td>
</tr>
</table>
---
### Insiders Mode
**Best for:** Users who want early access to experimental features and new tools before they reach general availability.
Insiders Mode unlocks experimental features, such as [MCP Apps](#mcp-apps) support. We created this mode to have a way to roll out experimental features and collect feedback. So if you are using Insiders, please don't hesitate to share your feedback with us! Features in Insiders Mode may change, evolve, or be removed based on user feedback.
<table>
<tr><th>Remote Server</th><th>Local Server</th></tr>
<tr valign="top">
<td>
**Option A: URL path**
```json
{
"type": "http",
"url": "https://api.githubcopilot.com/mcp/insiders"
}
```
**Option B: Header**
```json
{
"type": "http",
"url": "https://api.githubcopilot.com/mcp/",
"headers": {
"X-MCP-Insiders": "true"
}
}
```
</td>
<td>
```json
{
"type": "stdio",
"command": "go",
"args": [
"run",
"./cmd/github-mcp-server",
"stdio",
"--insiders"
],
"env": {
"GITHUB_PERSONAL_ACCESS_TOKEN": "${input:github_token}"
}
}
```
</td>
</tr>
</table>
See [Insiders Features](./insiders-features.md) for a full list of what's available in Insiders Mode.
---
### MCP Apps
[MCP Apps](https://modelcontextprotocol.io/docs/extensions/apps) is an extension to the Model Context Protocol that enables servers to deliver interactive user interfaces to end users. Instead of returning plain text that the LLM must interpret and relay, tools can render forms, profiles, and dashboards right in the chat.
MCP Apps is enabled by [Insiders Mode](#insiders-mode), or independently via the `remote_mcp_ui_apps` feature flag.
**Supported tools:**
| Tool | Description |
|------|-------------|
| `get_me` | Displays your GitHub user profile with avatar, bio, and stats in a rich card |
| `issue_write` | Opens an interactive form to create or update issues |
| `create_pull_request` | Provides a full PR creation form to create a pull request (or a draft pull request) |
**Client requirements:** MCP Apps requires a host that supports the [MCP Apps extension](https://modelcontextprotocol.io/docs/extensions/apps). Currently tested with VS Code (`chat.mcp.apps.enabled` setting).
<table>
<tr><th>Remote Server</th><th>Local Server</th></tr>
<tr valign="top">
<td>
```json
{
"type": "http",
"url": "https://api.githubcopilot.com/mcp/",
"headers": {
"X-MCP-Features": "remote_mcp_ui_apps"
}
}
```
</td>
<td>
```json
{
"type": "stdio",
"command": "go",
"args": [
"run",
"./cmd/github-mcp-server",
"stdio",
"--features=remote_mcp_ui_apps"
],
"env": {
"GITHUB_PERSONAL_ACCESS_TOKEN": "${input:github_token}"
}
}
```
</td>
</tr>
</table>
---
### 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 |
|---------|-------|----------|
| Server fails to start | Invalid tool name in `--tools` or `X-MCP-Tools` | Check tool name spelling; use exact names from [Tools list](../README.md#tools) |
| Write tools not working | Read-only mode enabled | Remove `--read-only` flag or `X-MCP-Readonly` header |
| Tools missing | Toolset not enabled | Add the required toolset or specific tool |
---
## Useful links
- [README: Tool Configuration](../README.md#tool-configuration)
- [README: Available Toolsets](../README.md#available-toolsets) — Complete list of toolsets
- [README: Tools](../README.md#tools) — Complete list of individual tools
- [Remote Server Documentation](./remote-server.md) — Remote-specific options and headers
- [Installation Guides](./installation-guides) — Host-specific setup instructions
+105
View File
@@ -0,0 +1,105 @@
# 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.
### Behind a Trusted Proxy (advanced)
By default, the server ignores the `X-Forwarded-Host` and `X-Forwarded-Proto` headers when constructing OAuth resource metadata URLs, so an untrusted client cannot influence the URL advertised to MCP clients. For most deployments, setting `--base-url` to the externally visible URL is the right approach.
If the server sits behind an internal forwarder that you fully control (for example, an in-cluster gateway that needs to preserve the originating hostname per request), you can opt into honoring those headers:
```bash
github-mcp-server http --trust-proxy-headers
```
Equivalent environment variable: `GITHUB_TRUST_PROXY_HEADERS=1`. Only enable this when the upstream proxy is trusted to set or strip these headers; otherwise prefer `--base-url`. When `--base-url` is set, it always takes precedence and `--trust-proxy-headers` has no effect.
## 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.
+34
View File
@@ -0,0 +1,34 @@
# Testing
This project uses a combination of unit tests and end-to-end (e2e) tests to ensure correctness and stability.
## Unit Testing Patterns
- 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.
- 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:
1. Test tool snapshot
1. Very important expectations against the schema (e.g. `ReadOnly` annotation)
1. Behavioural tests in table-driven form
## End-to-End (e2e) Tests
- E2E tests are located in the [`e2e/`](../e2e/) directory. See the [e2e/README.md](../e2e/README.md) for full details on running and debugging these tests.
## toolsnaps: Tool Schema Snapshots
- The `toolsnaps` utility ensures that the JSON schema for each tool does not change unexpectedly.
- Snapshots are stored in `__toolsnaps__/*.snap` files, where `*` represents the name of the tool
- When running tests, the current tool schema is compared to the snapshot. If there is a difference, the test will fail and show a diff.
- If you intentionally change a tool's schema, update the snapshots by running tests with the environment variable: `UPDATE_TOOLSNAPS=true go test ./...`
- In CI (when `GITHUB_ACTIONS=true`), missing snapshots will cause a test failure to ensure snapshots are always
committed.
## Notes
- Some tools that mutate global state (e.g., marking all notifications as read) are tested primarily with unit tests, not e2e, to avoid side effects.
- For more on the limitations and philosophy of the e2e suite, see the [e2e/README.md](../e2e/README.md).
+74
View File
@@ -0,0 +1,74 @@
# Tool Renaming Guide
How to safely rename MCP tools without breaking existing user configurations.
## Overview
When tools are renamed, users who have the old tool name in their MCP configuration (for example, in `X-MCP-Tools` headers for the remote MCP server or `--tools` flags for the local MCP server) would normally get errors.
The deprecation alias system allows us to maintain backward compatibility by silently resolving old tool names to their new canonical names.
This allows us to rename tools safely, without introducing breaking changes for users that have a hard reference to those tools in their server configuration.
## Quick Steps
1. **Rename the tool** in your code (as usual, this will imply a range of changes like updating the tool registration, the tests and the toolsnaps).
2. **Add a deprecation alias** in [pkg/github/deprecated_tool_aliases.go](../pkg/github/deprecated_tool_aliases.go):
```go
var DeprecatedToolAliases = map[string]string{
"old_tool_name": "new_tool_name",
}
```
3. **Update documentation** (README, etc.) to reference the new canonical name
That's it. The server will silently resolve old names to new ones. This will work across both local and remote MCP servers.
## Example
If renaming `get_issue` to `issue_read`:
```go
var DeprecatedToolAliases = map[string]string{
"get_issue": "issue_read",
}
```
A user with this configuration:
```json
{
"--tools": "get_issue,get_file_contents"
}
```
Will get `issue_read` and `get_file_contents` tools registered, with no errors.
## Current Deprecations
<!-- START AUTOMATED ALIASES -->
| Old Name | New Name |
|----------|----------|
| `add_project_item` | `projects_write` |
| `cancel_workflow_run` | `actions_run_trigger` |
| `delete_project_item` | `projects_write` |
| `delete_workflow_run_logs` | `actions_run_trigger` |
| `download_workflow_run_artifact` | `actions_get` |
| `get_project` | `projects_get` |
| `get_project_field` | `projects_get` |
| `get_project_item` | `projects_get` |
| `get_workflow` | `actions_get` |
| `get_workflow_job` | `actions_get` |
| `get_workflow_job_logs` | `actions_get` |
| `get_workflow_run` | `actions_get` |
| `get_workflow_run_logs` | `actions_get` |
| `get_workflow_run_usage` | `actions_get` |
| `list_project_fields` | `projects_list` |
| `list_project_items` | `projects_list` |
| `list_projects` | `projects_list` |
| `list_workflow_jobs` | `actions_list` |
| `list_workflow_run_artifacts` | `actions_list` |
| `list_workflow_runs` | `actions_list` |
| `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` |
<!-- END AUTOMATED ALIASES -->
+201
View File
@@ -0,0 +1,201 @@
# Toolsets and Icons
This document explains how to work with toolsets and icons in the GitHub MCP Server.
## Toolset Overview
Toolsets are logical groupings of related tools. Each toolset has metadata defined in `pkg/github/tools.go`:
```go
ToolsetMetadataRepos = inventory.ToolsetMetadata{
ID: "repos",
Description: "GitHub Repository related tools",
Default: true,
Icon: "repo",
}
```
### Toolset Fields
| Field | Type | Description |
|-------|------|-------------|
| `ID` | `ToolsetID` | Unique identifier used in URLs and CLI flags (e.g., `repos`, `issues`) |
| `Description` | `string` | Human-readable description shown in documentation |
| `Default` | `bool` | Whether this toolset is enabled by default |
| `Icon` | `string` | Octicon name for visual representation in MCP clients |
## Adding Icons to Toolsets
Icons help users quickly identify toolsets in MCP-compatible clients. We use [Primer Octicons](https://primer.style/foundations/icons) for all icons.
### Step 1: Choose an Octicon
Browse the [Octicon gallery](https://primer.style/foundations/icons) and select an appropriate icon. Use the base name without size suffix (e.g., `repo` not `repo-16`).
### Step 2: Add Icon to Required Icons List
Icons are defined in `pkg/octicons/required_icons.txt`, which is the single source of truth for which icons should be embedded:
```
# Required icons for the GitHub MCP Server
# Add new icons below (one per line)
repo
issue-opened
git-pull-request
your-new-icon # Add your icon here
```
### Step 3: Fetch the Icon Files
Run the fetch-icons script to download and convert the icon:
```bash
# Fetch a specific icon
script/fetch-icons your-new-icon
# Or fetch all required icons
script/fetch-icons
```
This script:
- Downloads the 24px SVG from [Primer Octicons](https://github.com/primer/octicons)
- Converts to PNG with light theme (dark icons for light backgrounds)
- Converts to PNG with dark theme (white icons for dark backgrounds)
- Saves both variants to `pkg/octicons/icons/`
**Requirements:** The script requires `rsvg-convert`:
- Ubuntu/Debian: `sudo apt-get install librsvg2-bin`
- macOS: `brew install librsvg`
### Step 4: Update the Toolset Metadata
Add or update the `Icon` field in the toolset definition:
```go
// In pkg/github/tools.go
ToolsetMetadataRepos = inventory.ToolsetMetadata{
ID: "repos",
Description: "GitHub Repository related tools",
Default: true,
Icon: "repo", // Add this line
}
```
### Step 5: Regenerate Documentation
Run the documentation generator to update all markdown files:
```bash
go run ./cmd/github-mcp-server generate-docs
```
This updates icons in:
- `README.md` - Toolsets table and tool section headers
- `docs/remote-server.md` - Remote toolsets table
## Remote-Only Toolsets
Some toolsets are only available in the remote GitHub MCP Server (hosted at `api.githubcopilot.com`). These are defined in `pkg/github/tools.go` with their icons, but are not registered with the local server:
```go
// Remote-only toolsets
ToolsetMetadataCopilot = inventory.ToolsetMetadata{
ID: "copilot",
Description: "Copilot related tools",
Icon: "copilot",
}
```
The `RemoteOnlyToolsets()` function returns the list of these toolsets for documentation generation.
To add a new remote-only toolset:
1. Add the metadata definition in `pkg/github/tools.go`
2. Add it to the slice returned by `RemoteOnlyToolsets()`
3. Regenerate documentation
## Tool Icon Inheritance
Individual tools inherit icons from their parent toolset. When a tool is registered with a toolset, its icons are automatically set:
```go
// In pkg/inventory/server_tool.go
toolCopy.Icons = tool.Toolset.Icons()
```
This means you only need to set the icon once on the toolset, and all tools in that toolset will display the same icon.
## How Icons Work in MCP
The MCP protocol supports tool icons via the `icons` field. We provide icons in two formats:
1. **Data URIs** - Base64-encoded PNG images embedded in the tool definition
2. **Light/Dark variants** - Both theme variants are provided for proper display
The `octicons.Icons()` function generates the MCP-compatible icon objects:
```go
// Returns []mcp.Icon with both light and dark variants
icons := octicons.Icons("repo")
```
## Existing Toolset Icons
| Toolset | Octicon Name |
|---------|--------------|
| Context | `person` |
| Repositories | `repo` |
| Issues | `issue-opened` |
| Pull Requests | `git-pull-request` |
| Git | `git-branch` |
| Users | `people` |
| Organizations | `organization` |
| Actions | `workflow` |
| Code Quality | `code-square` |
| Code Security | `codescan` |
| Secret Protection | `shield-lock` |
| Dependabot | `dependabot` |
| Discussions | `comment-discussion` |
| Gists | `logo-gist` |
| Security Advisories | `shield` |
| Projects | `project` |
| Labels | `tag` |
| Stargazers | `star` |
| Notifications | `bell` |
| Copilot | `copilot` |
| Support Search | `book` |
## Troubleshooting
### Icons not appearing in documentation
1. Ensure PNG files exist in `pkg/octicons/icons/` with `-light.png` and `-dark.png` suffixes
2. Run `go run ./cmd/github-mcp-server generate-docs` to regenerate
3. Check that the `Icon` field is set on the toolset metadata
### Icons not appearing in MCP clients
1. Verify the client supports MCP tool icons
2. Check that the octicons package is properly generating base64 data URIs
3. Ensure the icon name matches a file in `pkg/octicons/icons/`
## CI Validation
The following tests run in CI to catch icon issues early:
### `pkg/octicons.TestEmbeddedIconsExist`
Verifies that all icons listed in `pkg/octicons/required_icons.txt` have corresponding PNG files embedded.
### `pkg/github.TestAllToolsetIconsExist`
Verifies that all toolset `Icon` fields reference icons that are properly embedded.
### `pkg/github.TestToolsetMetadataHasIcons`
Ensures all toolsets have an `Icon` field set.
If any of these tests fail:
1. Add the missing icon to `pkg/octicons/required_icons.txt`
2. Run `script/fetch-icons` to download the icon
3. Commit the new icon files
+96
View File
@@ -0,0 +1,96 @@
# End To End (e2e) Tests
The purpose of the E2E tests is to have a simple (currently) test that gives maintainers some confidence in the black box behavior of our artifacts. It does this by:
* Building the `github-mcp-server` docker image
* Running the image
* Interacting with the server via stdio
* Issuing requests that interact with the live GitHub API
## Running the Tests
A service must be running that supports image building and container creation via the `docker` CLI.
Since these tests require a token to interact with real resources on the GitHub API, it is gated behind the `e2e` build flag.
```
GITHUB_MCP_SERVER_E2E_TOKEN=<YOUR TOKEN> go test -v --tags e2e ./e2e
```
The `GITHUB_MCP_SERVER_E2E_TOKEN` environment variable is mapped to `GITHUB_PERSONAL_ACCESS_TOKEN` internally, but separated to avoid accidental reuse of credentials.
## Example
The following diff adjusts the `get_me` tool to return `foobar` as the user login.
```diff
diff --git a/pkg/github/context_tools.go b/pkg/github/context_tools.go
index 1c91d70..ac4ef2b 100644
--- a/pkg/github/context_tools.go
+++ b/pkg/github/context_tools.go
@@ -39,6 +39,8 @@ func GetMe(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mc
return mcp.NewToolResultError(fmt.Sprintf("failed to get user: %s", string(body))), nil
}
+ user.Login = sPtr("foobar")
+
r, err := json.Marshal(user)
if err != nil {
return nil, fmt.Errorf("failed to marshal user: %w", err)
@@ -47,3 +49,7 @@ func GetMe(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mc
return mcp.NewToolResultText(string(r)), nil
}
}
+
+func sPtr(s string) *string {
+ return &s
+}
```
Running the tests:
```
➜ GITHUB_MCP_SERVER_E2E_TOKEN=$(gh auth token) go test -v --tags e2e ./e2e
=== RUN TestE2E
e2e_test.go:92: Building Docker image for e2e tests...
e2e_test.go:36: Starting Stdio MCP client...
=== RUN TestE2E/Initialize
=== RUN TestE2E/CallTool_get_me
e2e_test.go:85:
Error Trace: /Users/williammartin/workspace/github-mcp-server/e2e/e2e_test.go:85
Error: Not equal:
expected: "foobar"
actual : "williammartin"
Diff:
--- Expected
+++ Actual
@@ -1 +1 @@
-foobar
+williammartin
Test: TestE2E/CallTool_get_me
Messages: expected login to match
--- FAIL: TestE2E (1.05s)
--- PASS: TestE2E/Initialize (0.09s)
--- FAIL: TestE2E/CallTool_get_me (0.46s)
FAIL
FAIL github.com/github/github-mcp-server/e2e 1.433s
FAIL
```
## Debugging the Tests
It is possible to provide `GITHUB_MCP_SERVER_E2E_DEBUG=true` to run the e2e tests with an in-process version of the MCP server. This has slightly reduced coverage as it doesn't integrate with Docker, or make use of the cobra/viper configuration parsing. However, it allows for placing breakpoints in the MCP Server internals, supporting much better debugging flows than the fully black-box tests.
One might argue that the lack of visibility into failures for the black box tests also indicates a product need, but this solves for the immediate pain point felt as a maintainer.
## Limitations
The current test suite is intentionally very limited in scope. This is because the maintenance costs on e2e tests tend to increase significantly over time. To read about some challenges with GitHub integration tests, see [go-github integration tests README](https://github.com/google/go-github/blob/5b75aa86dba5cf4af2923afa0938774f37fa0a67/test/README.md). We will expand this suite circumspectly!
The tests are quite repetitive and verbose. This is intentional as we want to see them develop more before committing to abstractions.
Currently, visibility into failures is not particularly good. We're hoping that we can pull apart the mcp-go client and have it hook into streams representing stdio without requiring an exec. This way we can get breakpoints in the debugger easily.
### Global State Mutation Tests
Some tools (such as those that mark all notifications as read) would change the global state for the tester, and are also not idempotent, so they offer little value for end to end tests and instead should rely on unit testing and manual verifications.
+1711
View File
File diff suppressed because it is too large Load Diff
+13
View File
@@ -0,0 +1,13 @@
{
"name": "github",
"version": "1.0.0",
"mcpServers": {
"github": {
"description": "Connect AI assistants to GitHub - manage repos, issues, PRs, and workflows through natural language.",
"httpUrl": "https://api.githubcopilot.com/mcp/",
"headers": {
"Authorization": "Bearer $GITHUB_MCP_PAT"
}
}
}
}
+50
View File
@@ -0,0 +1,50 @@
module github.com/github/github-mcp-server
go 1.25.0
require (
github.com/go-chi/chi/v5 v5.3.0
github.com/go-viper/mapstructure/v2 v2.5.0
github.com/google/go-github/v89 v89.0.0
github.com/google/jsonschema-go v0.4.3
github.com/josephburnett/jd/v2 v2.5.0
github.com/lithammer/fuzzysearch v1.1.8
github.com/microcosm-cc/bluemonday v1.0.27
github.com/modelcontextprotocol/go-sdk v1.7.0-pre.1
github.com/muesli/cache2go v0.0.0-20221011235721-518229cd8021
github.com/shurcooL/githubv4 v0.0.0-20240727222349-48295856cce7
github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466
github.com/spf13/cobra v1.10.2
github.com/spf13/pflag v1.0.10
github.com/spf13/viper v1.21.0
github.com/stretchr/testify v1.11.1
github.com/yosida95/uritemplate/v3 v3.0.2
golang.org/x/oauth2 v0.35.0
)
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/google/go-querystring v1.2.0 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect
github.com/segmentio/asm v1.1.3 // indirect
github.com/segmentio/encoding v0.5.4 // indirect
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/net v0.55.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.45.0 // indirect
golang.org/x/text v0.37.0 // indirect
golang.org/x/time v0.15.0 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+135
View File
@@ -0,0 +1,135 @@
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM=
github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
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.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
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/v89 v89.0.0 h1:35bEK5XoEcF3PZrlVbl9XN63f5BcJRA/UGkxeC9xPg0=
github.com/google/go-github/v89 v89.0.0/go.mod h1:QLcbU0ipeAqQuR5KSg8c2lql4Qk1EwJ2dWz/0rP4Nho=
github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0=
github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU=
github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
github.com/google/jsonschema-go v0.4.3/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/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/josephburnett/jd/v2 v2.5.0 h1:c1G9TXeozJINRGZDeN2Z000Ok2Z8+0h0rbBRSdF79CY=
github.com/josephburnett/jd/v2 v2.5.0/go.mod h1:G6F+v/jcqS0b0d6LIyi1xC+wLleSKN8HvrqBhmBC8b8=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
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/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/modelcontextprotocol/go-sdk v1.7.0-pre.1 h1:GlMIJyMHFX76bBSQuBCLXZ7pB9cGh4VBS6O5wGd0tgI=
github.com/modelcontextprotocol/go-sdk v1.7.0-pre.1/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts=
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/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0=
github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
github.com/shurcooL/githubv4 v0.0.0-20240727222349-48295856cce7 h1:cYCy18SHPKRkvclm+pWm1Lk4YrREb4IOIb/YdFO0p2M=
github.com/shurcooL/githubv4 v0.0.0-20240727222349-48295856cce7/go.mod h1:zqMwyHmnN/eDOZOdiTohqIUKUrTFX62PNlu7IJdu0q8=
github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466 h1:17JxqqJY66GmZVHkmAsGEkcIu0oCe3AM420QDgGwZx0=
github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466/go.mod h1:9dIRpgIY7hVhoqfe0/FcYp0bpInZaT7dc3BYOprrIUE=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
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.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=
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
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/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.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
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/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
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.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
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.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
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.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/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-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+19
View File
@@ -0,0 +1,19 @@
// Package buildinfo contains variables that are set at build time via ldflags.
// These allow official releases to ship default OAuth credentials so users can
// log in without configuring their own OAuth app. The values are public in
// practice (security relies on PKCE, not on the client secret), but are kept out
// of source and injected at build time.
//
// Example:
//
// go build -ldflags="-X github.com/github/github-mcp-server/internal/buildinfo.OAuthClientID=xxx"
package buildinfo
// OAuthClientID is the default OAuth client ID, set at build time. Empty in
// local/dev builds.
var OAuthClientID string
// OAuthClientSecret is the default OAuth client secret, set at build time. For
// public OAuth clients it is not truly secret per OAuth 2.1 — PKCE provides the
// security — but it is still injected at build time rather than committed.
var OAuthClientSecret string
+133
View File
@@ -0,0 +1,133 @@
package ghmcp
import (
"context"
"crypto/rand"
"fmt"
"log/slog"
"github.com/github/github-mcp-server/internal/oauth"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// sessionPrompter adapts an MCP server session to oauth.Prompter, presenting
// authorization prompts to the user via elicitation. Keeping the prompt on the
// MCP control channel (rather than a tool result) keeps the authorization URL
// and any session-bound state out of the model's context.
type sessionPrompter struct {
session *mcp.ServerSession
}
// elicitationCaps returns the client's declared elicitation capabilities, or nil
// if the client did not advertise any.
func (p *sessionPrompter) elicitationCaps() *mcp.ElicitationCapabilities {
params := p.session.InitializeParams()
if params == nil || params.Capabilities == nil {
return nil
}
return params.Capabilities.Elicitation
}
// CanPromptURL reports whether the client supports URL-mode elicitation.
func (p *sessionPrompter) CanPromptURL() bool {
caps := p.elicitationCaps()
return caps != nil && caps.URL != nil
}
// PromptURL presents the authorization URL via URL-mode elicitation and blocks
// until the user acknowledges, declines, or ctx is done.
func (p *sessionPrompter) PromptURL(ctx context.Context, prompt oauth.Prompt) error {
res, err := p.session.Elicit(ctx, &mcp.ElicitParams{
Mode: "url",
Message: prompt.Message,
URL: prompt.URL,
ElicitationID: rand.Text(),
})
if err != nil {
// The client advertised URL elicitation but the request itself failed:
// classify it as undeliverable (not a user decision) so the flow can fall
// back to a channel that needs no client capability.
return fmt.Errorf("%w: %w", oauth.ErrPromptUnavailable, err)
}
if res.Action != "accept" {
return oauth.ErrPromptDeclined
}
return nil
}
// CanPromptForm reports whether the client supports form-mode elicitation. The
// SDK treats a client that advertises neither form nor URL capabilities as
// supporting forms, for backward compatibility, so we mirror that here.
func (p *sessionPrompter) CanPromptForm() bool {
caps := p.elicitationCaps()
if caps == nil {
return false
}
return caps.Form != nil || caps.URL == nil
}
// PromptForm presents a textual acknowledgement (used to display a device code
// when URL elicitation is unavailable) and blocks until the user responds.
func (p *sessionPrompter) PromptForm(ctx context.Context, prompt oauth.Prompt) error {
res, err := p.session.Elicit(ctx, &mcp.ElicitParams{
Mode: "form",
Message: prompt.Message,
})
if err != nil {
// As with PromptURL, a delivery failure is undeliverable rather than a
// decline, so the flow can fall back instead of aborting.
return fmt.Errorf("%w: %w", oauth.ErrPromptUnavailable, err)
}
if res.Action != "accept" {
return oauth.ErrPromptDeclined
}
return nil
}
// oauthAuthenticator is the subset of *oauth.Manager that the middleware needs.
// Depending on the interface (rather than the concrete manager) lets the
// middleware be exercised with a deterministic fake, since driving the real
// manager to its branches would require standing up live GitHub flows.
type oauthAuthenticator interface {
HasToken() bool
Authenticate(ctx context.Context, prompter oauth.Prompter) (*oauth.Outcome, error)
}
// createOAuthMiddleware returns receiving middleware that authorizes the session
// lazily, on the first tool call. Authorization is deferred until here (rather
// than at startup) because the prompts depend on an initialized session whose
// elicitation capabilities are known.
//
// When a token is already available the call proceeds untouched. Otherwise the
// flow runs: secure channels (browser, URL elicitation) block until the token
// arrives and then the call proceeds; the last-resort channel returns the
// instruction to the user as a tool result and asks them to retry.
func createOAuthMiddleware(mgr oauthAuthenticator, logger *slog.Logger) func(next mcp.MethodHandler) mcp.MethodHandler {
return func(next mcp.MethodHandler) mcp.MethodHandler {
return func(ctx context.Context, method string, request mcp.Request) (mcp.Result, error) {
if method != "tools/call" || mgr.HasToken() {
return next(ctx, method, request)
}
callReq, ok := request.(*mcp.CallToolRequest)
if !ok {
return next(ctx, method, request)
}
outcome, err := mgr.Authenticate(ctx, &sessionPrompter{session: callReq.Session})
if err != nil {
return nil, fmt.Errorf("github authorization failed: %w", err)
}
if outcome != nil && outcome.UserAction != nil {
logger.Info("surfacing github authorization instructions to user")
return &mcp.CallToolResult{
Content: []mcp.Content{&mcp.TextContent{Text: outcome.UserAction.Message}},
}, nil
}
return next(ctx, method, request)
}
}
}
// ensure sessionPrompter satisfies the Prompter contract.
var _ oauth.Prompter = (*sessionPrompter)(nil)
+391
View File
@@ -0,0 +1,391 @@
package ghmcp
import (
"context"
"errors"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"testing"
"github.com/github/github-mcp-server/internal/oauth"
"github.com/github/github-mcp-server/pkg/github"
"github.com/github/github-mcp-server/pkg/http/headers"
"github.com/github/github-mcp-server/pkg/utils"
"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func discardLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
// probeToolName is the name of the throwaway tool the harness registers; its
// handler runs a probe closure against a sessionPrompter so the adapter can be
// exercised against a real, fully-negotiated server session from the client side.
const probeToolName = "probe"
// runProbe stands up an in-memory MCP client/server pair, registers a tool whose
// handler runs probe against a sessionPrompter wrapping the live server session,
// and returns the text the probe produced. The client is configured with the
// given capabilities and elicitation handler so the adapter sees a real,
// fully-negotiated session rather than a hand-built fake.
func runProbe(
t *testing.T,
clientCaps *mcp.ClientCapabilities,
elicitationHandler func(context.Context, *mcp.ElicitRequest) (*mcp.ElicitResult, error),
probe func(context.Context, *sessionPrompter) string,
) string {
t.Helper()
server := mcp.NewServer(&mcp.Implementation{Name: "test-server", Version: "v0.0.1"}, nil)
mcp.AddTool(server, &mcp.Tool{Name: probeToolName}, func(ctx context.Context, req *mcp.CallToolRequest, _ struct{}) (*mcp.CallToolResult, any, error) {
text := probe(ctx, &sessionPrompter{session: req.Session})
return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: text}}}, nil, nil
})
st, ct := mcp.NewInMemoryTransports()
ss, err := server.Connect(context.Background(), st, nil)
require.NoError(t, err)
t.Cleanup(func() { _ = ss.Close() })
client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "v0.0.1"}, &mcp.ClientOptions{
Capabilities: clientCaps,
ElicitationHandler: elicitationHandler,
})
cs, err := client.Connect(context.Background(), ct, nil)
require.NoError(t, err)
t.Cleanup(func() { _ = cs.Close() })
res, err := cs.CallTool(context.Background(), &mcp.CallToolParams{Name: probeToolName})
require.NoError(t, err)
require.Len(t, res.Content, 1)
text, ok := res.Content[0].(*mcp.TextContent)
require.True(t, ok, "probe result should be text content")
return text.Text
}
func TestSessionPrompterCapabilities(t *testing.T) {
t.Parallel()
tests := []struct {
name string
caps *mcp.ClientCapabilities
wantURL bool
wantForm bool
}{
{
name: "no elicitation advertised",
caps: &mcp.ClientCapabilities{},
wantURL: false,
wantForm: false,
},
{
name: "url only",
caps: &mcp.ClientCapabilities{Elicitation: &mcp.ElicitationCapabilities{URL: &mcp.URLElicitationCapabilities{}}},
wantURL: true,
wantForm: false,
},
{
name: "form only",
caps: &mcp.ClientCapabilities{Elicitation: &mcp.ElicitationCapabilities{Form: &mcp.FormElicitationCapabilities{}}},
wantURL: false,
wantForm: true,
},
{
name: "url and form",
caps: &mcp.ClientCapabilities{Elicitation: &mcp.ElicitationCapabilities{URL: &mcp.URLElicitationCapabilities{}, Form: &mcp.FormElicitationCapabilities{}}},
wantURL: true,
wantForm: true,
},
{
name: "empty elicitation capability implies form for backward compatibility",
caps: &mcp.ClientCapabilities{Elicitation: &mcp.ElicitationCapabilities{}},
wantURL: false,
wantForm: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got := runProbe(t, tc.caps, nil, func(_ context.Context, p *sessionPrompter) string {
if p.CanPromptURL() {
if p.CanPromptForm() {
return "url+form"
}
return "url"
}
if p.CanPromptForm() {
return "form"
}
return "none"
})
want := "none"
switch {
case tc.wantURL && tc.wantForm:
want = "url+form"
case tc.wantURL:
want = "url"
case tc.wantForm:
want = "form"
}
assert.Equal(t, want, got)
})
}
}
func TestSessionPrompterPromptActions(t *testing.T) {
t.Parallel()
tests := []struct {
name string
action string
wantDecline bool
}{
{name: "accept", action: "accept", wantDecline: false},
{name: "decline", action: "decline", wantDecline: true},
{name: "cancel", action: "cancel", wantDecline: true},
}
caps := &mcp.ClientCapabilities{Elicitation: &mcp.ElicitationCapabilities{
URL: &mcp.URLElicitationCapabilities{},
Form: &mcp.FormElicitationCapabilities{},
}}
for _, tc := range tests {
// URL and form modes share the accept/decline mapping; cover both.
for _, mode := range []string{"url", "form"} {
t.Run(tc.name+"/"+mode, func(t *testing.T) {
t.Parallel()
handler := func(_ context.Context, _ *mcp.ElicitRequest) (*mcp.ElicitResult, error) {
return &mcp.ElicitResult{Action: tc.action}, nil
}
got := runProbe(t, caps, handler, func(ctx context.Context, p *sessionPrompter) string {
var err error
if mode == "url" {
err = p.PromptURL(ctx, oauth.Prompt{Message: "msg", URL: "https://example.com/auth"})
} else {
err = p.PromptForm(ctx, oauth.Prompt{Message: "msg"})
}
if err == nil {
return "ok"
}
if err == oauth.ErrPromptDeclined {
return "declined"
}
return "error: " + err.Error()
})
if tc.wantDecline {
assert.Equal(t, "declined", got)
} else {
assert.Equal(t, "ok", got)
}
})
}
}
}
// TestSessionPrompterTransportError verifies that a prompt which fails to be
// delivered (the client errors instead of returning an action) is reported as
// ErrPromptUnavailable, not ErrPromptDeclined. The manager relies on this
// distinction to fall back to manual instructions instead of aborting.
func TestSessionPrompterTransportError(t *testing.T) {
t.Parallel()
caps := &mcp.ClientCapabilities{Elicitation: &mcp.ElicitationCapabilities{
URL: &mcp.URLElicitationCapabilities{},
Form: &mcp.FormElicitationCapabilities{},
}}
for _, mode := range []string{"url", "form"} {
t.Run(mode, func(t *testing.T) {
t.Parallel()
handler := func(_ context.Context, _ *mcp.ElicitRequest) (*mcp.ElicitResult, error) {
return nil, errors.New("client cannot deliver elicitation")
}
got := runProbe(t, caps, handler, func(ctx context.Context, p *sessionPrompter) string {
var err error
if mode == "url" {
err = p.PromptURL(ctx, oauth.Prompt{Message: "msg", URL: "https://example.com/auth"})
} else {
err = p.PromptForm(ctx, oauth.Prompt{Message: "msg"})
}
switch {
case err == nil:
return "ok"
case errors.Is(err, oauth.ErrPromptDeclined):
return "declined"
case errors.Is(err, oauth.ErrPromptUnavailable):
return "unavailable"
default:
return "error: " + err.Error()
}
})
assert.Equal(t, "unavailable", got,
"a delivery failure must be classified as undeliverable, not a decline")
})
}
}
// fakeAuthenticator is a deterministic stand-in for *oauth.Manager that lets the
// middleware be tested at each branch without standing up live GitHub flows.
type fakeAuthenticator struct {
hasToken bool
outcome *oauth.Outcome
err error
authCalls int
lastPrompter oauth.Prompter
}
func (f *fakeAuthenticator) HasToken() bool { return f.hasToken }
func (f *fakeAuthenticator) Authenticate(_ context.Context, prompter oauth.Prompter) (*oauth.Outcome, error) {
f.authCalls++
f.lastPrompter = prompter
return f.outcome, f.err
}
func TestCreateOAuthMiddleware(t *testing.T) {
t.Parallel()
const nextText = "handler-ran"
newNext := func(called *bool) mcp.MethodHandler {
return func(_ context.Context, _ string, _ mcp.Request) (mcp.Result, error) {
*called = true
return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: nextText}}}, nil
}
}
t.Run("non tool call passes through without authenticating", func(t *testing.T) {
t.Parallel()
fake := &fakeAuthenticator{hasToken: false}
var called bool
mw := createOAuthMiddleware(fake, discardLogger())
_, err := mw(newNext(&called))(context.Background(), "initialize", &mcp.InitializeRequest{})
require.NoError(t, err)
assert.True(t, called, "next should run")
assert.Zero(t, fake.authCalls, "authentication must not run for non tool calls")
})
t.Run("existing token short circuits authentication", func(t *testing.T) {
t.Parallel()
fake := &fakeAuthenticator{hasToken: true}
var called bool
mw := createOAuthMiddleware(fake, discardLogger())
_, err := mw(newNext(&called))(context.Background(), "tools/call", &mcp.CallToolRequest{})
require.NoError(t, err)
assert.True(t, called, "next should run")
assert.Zero(t, fake.authCalls, "authentication must be skipped when a token already exists")
})
t.Run("successful authentication proceeds to handler", func(t *testing.T) {
t.Parallel()
fake := &fakeAuthenticator{hasToken: false, outcome: nil, err: nil}
var called bool
mw := createOAuthMiddleware(fake, discardLogger())
res, err := mw(newNext(&called))(context.Background(), "tools/call", &mcp.CallToolRequest{})
require.NoError(t, err)
assert.Equal(t, 1, fake.authCalls)
assert.True(t, called, "next should run once authorized")
callRes, ok := res.(*mcp.CallToolResult)
require.True(t, ok)
require.Len(t, callRes.Content, 1)
assert.Equal(t, nextText, callRes.Content[0].(*mcp.TextContent).Text)
})
t.Run("pending user action is surfaced as a tool result", func(t *testing.T) {
t.Parallel()
const message = "Open https://example.com/auth to authorize, then retry."
fake := &fakeAuthenticator{hasToken: false, outcome: &oauth.Outcome{UserAction: &oauth.UserAction{Message: message}}}
var called bool
mw := createOAuthMiddleware(fake, discardLogger())
res, err := mw(newNext(&called))(context.Background(), "tools/call", &mcp.CallToolRequest{})
require.NoError(t, err)
assert.False(t, called, "next must not run while the user still needs to authorize")
callRes, ok := res.(*mcp.CallToolResult)
require.True(t, ok)
require.Len(t, callRes.Content, 1)
assert.Equal(t, message, callRes.Content[0].(*mcp.TextContent).Text)
})
t.Run("authentication error is returned", func(t *testing.T) {
t.Parallel()
fake := &fakeAuthenticator{hasToken: false, err: assert.AnError}
var called bool
mw := createOAuthMiddleware(fake, discardLogger())
_, err := mw(newNext(&called))(context.Background(), "tools/call", &mcp.CallToolRequest{})
require.Error(t, err)
assert.ErrorIs(t, err, assert.AnError)
assert.False(t, called, "next must not run when authentication fails")
})
}
// TestRunStdioServerRejectsTokenAndOAuth verifies the mutually-exclusive guard:
// supplying both a static token and an OAuth manager is rejected before the
// server starts, rather than silently preferring one for auth and the other for
// scope filtering.
func TestRunStdioServerRejectsTokenAndOAuth(t *testing.T) {
t.Parallel()
mgr := oauth.NewManager(oauth.NewGitHubConfig("client-id", "", nil, "", 0), discardLogger())
err := RunStdioServer(StdioServerConfig{
Token: "ghp_static",
OAuthManager: mgr,
})
require.Error(t, err)
assert.Contains(t, err.Error(), "mutually exclusive")
}
// TestCreateGitHubClientsTokenProvider proves the OAuth wiring: when a
// TokenProvider is configured the REST client authenticates with the provider's
// current token on every request (and never pins a stale one), which is what the
// lazy, refreshing OAuth token depends on.
func TestCreateGitHubClientsTokenProvider(t *testing.T) {
t.Parallel()
var gotAuth string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth = r.Header.Get(headers.AuthorizationHeader)
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
current := ""
apiHost, err := utils.NewAPIHost(server.URL)
require.NoError(t, err)
clients, err := createGitHubClients(github.MCPServerConfig{
Version: "test",
TokenProvider: func() string { return current },
}, apiHost)
require.NoError(t, err)
do := func() {
resp, err := clients.rest.Client().Get(server.URL)
require.NoError(t, err)
defer resp.Body.Close()
}
do()
assert.Equal(t, "", gotAuth, "no auth header before authorization")
current = "oauth-token"
do()
assert.Equal(t, "Bearer oauth-token", gotAuth, "provider token used once available")
current = "refreshed-token"
do()
assert.Equal(t, "Bearer refreshed-token", gotAuth, "refreshed provider token used")
}
+446
View File
@@ -0,0 +1,446 @@
package ghmcp
import (
"context"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
"github.com/github/github-mcp-server/internal/oauth"
"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/observability"
"github.com/github/github-mcp-server/pkg/observability/metrics"
"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/v89/github"
"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/shurcooL/githubv4"
)
// githubClients holds all the GitHub API clients created for a server instance.
type githubClients struct {
rest *gogithub.Client
restUATransp *transport.UserAgentTransport
gql *githubv4.Client
gqlHTTP *http.Client // retained for middleware to modify transport
raw *raw.Client
repoAccess *lockdown.RepoAccessCache
}
// createGitHubClients creates all the GitHub API clients needed by the server.
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. When a TokenProvider is configured (OAuth), we
// authenticate via BearerAuthTransport and skip go-github's WithAuthToken:
// the latter installs its own round tripper that would pin the static token
// and shadow the dynamic one.
restUATransport := &transport.UserAgentTransport{
Transport: http.DefaultTransport,
Agent: fmt.Sprintf("github-mcp-server/%s", cfg.Version),
}
var restClient *gogithub.Client
if cfg.TokenProvider != nil {
restClient, err = gogithub.NewClient(
gogithub.WithHTTPClient(&http.Client{Transport: &transport.BearerAuthTransport{
Transport: restUATransport,
TokenProvider: cfg.TokenProvider,
}}),
gogithub.WithEnterpriseURLs(restURL.String(), uploadURL.String()),
)
} else {
restClient, err = gogithub.NewClient(
gogithub.WithHTTPClient(&http.Client{Transport: restUATransport}),
gogithub.WithAuthToken(cfg.Token),
gogithub.WithEnterpriseURLs(restURL.String(), uploadURL.String()),
)
}
if err != nil {
return nil, fmt.Errorf("failed to create REST client: %w", err)
}
// Construct GraphQL client
// We use NewEnterpriseClient unconditionally since we already parsed the API host
gqlHTTPClient := &http.Client{
Transport: &transport.BearerAuthTransport{
Transport: &transport.GraphQLFeaturesTransport{
Transport: http.DefaultTransport,
},
Token: cfg.Token,
TokenProvider: cfg.TokenProvider,
},
}
gqlClient := githubv4.NewEnterpriseClient(graphQLURL.String(), gqlHTTPClient)
// Create raw content client (shares REST client's HTTP transport)
rawClient, err := raw.NewClient(restClient, rawURL)
if err != nil {
return nil, fmt.Errorf("failed to create raw client: %w", err)
}
// Set up repo access cache for lockdown mode
var repoAccessCache *lockdown.RepoAccessCache
if cfg.LockdownMode {
opts := []lockdown.RepoAccessOption{
lockdown.WithLogger(cfg.Logger.With("component", "lockdown")),
}
if cfg.RepoAccessTTL != nil {
opts = append(opts, lockdown.WithTTL(*cfg.RepoAccessTTL))
}
repoAccessCache = lockdown.NewRepoAccessCache(gqlClient, restClient, opts...)
}
return &githubClients{
rest: restClient,
restUATransp: restUATransport,
gql: gqlClient,
gqlHTTP: gqlHTTPClient,
raw: rawClient,
repoAccess: repoAccessCache,
}, nil
}
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)
}
clients, err := createGitHubClients(cfg, apiHost)
if err != nil {
return nil, fmt.Errorf("failed to create GitHub clients: %w", err)
}
// Create feature checker — resolves explicit features + insiders expansion
featureChecker := createFeatureChecker(cfg.EnabledFeatures, cfg.InsidersMode)
// Create dependencies for tool handlers
obs, err := observability.NewExporters(cfg.Logger, metrics.NewNoopMetrics())
if err != nil {
return nil, fmt.Errorf("failed to create observability exporters: %w", err)
}
deps := github.NewBaseDeps(
clients.rest,
clients.gql,
clients.raw,
clients.repoAccess,
cfg.Translator,
github.FeatureFlags{
LockdownMode: cfg.LockdownMode,
},
cfg.ContentWindowSize,
featureChecker,
obs,
)
// Build and register the tool/resource/prompt inventory
inventoryBuilder := github.NewInventory(cfg.Translator).
WithDeprecatedAliases(github.DeprecatedToolAliases).
WithReadOnly(cfg.ReadOnly).
WithToolsets(github.ResolvedEnabledToolsets(cfg.EnabledToolsets, cfg.EnabledTools)).
WithTools(github.CleanTools(cfg.EnabledTools)).
WithExcludeTools(cfg.ExcludeTools).
WithServerInstructions().
WithFeatureChecker(featureChecker)
// Apply token scope filtering if scopes are known (for PAT filtering)
if cfg.TokenScopes != nil {
inventoryBuilder = inventoryBuilder.WithFilter(github.CreateToolScopeFilter(cfg.TokenScopes))
}
inventory, err := inventoryBuilder.Build()
if err != nil {
return nil, fmt.Errorf("failed to build inventory: %w", err)
}
ghServer, err := github.NewMCPServer(ctx, &cfg, deps, inventory)
if err != nil {
return nil, fmt.Errorf("failed to create GitHub MCP server: %w", err)
}
ghServer.AddReceivingMiddleware(addUserAgentsMiddleware(cfg, clients.restUATransp, clients.gqlHTTP))
return ghServer, nil
}
type StdioServerConfig 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
// ReadOnly indicates if we should only register read-only tools
ReadOnly bool
// 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
// InsidersMode expands to the curated set of feature flags enabled for insiders.
InsidersMode bool
// ExcludeTools is a list of tool names to disable regardless of other settings.
// These tools will be excluded even if their toolset is enabled or they are
// explicitly listed in EnabledTools.
ExcludeTools []string
// RepoAccessCacheTTL overrides the default TTL for repository access cache entries.
RepoAccessCacheTTL *time.Duration
// OAuthManager, when non-nil, enables OAuth 2.1 login for stdio mode. The
// server starts without a token and runs the authorization flow on the
// first tool call (see createOAuthMiddleware). It is mutually exclusive with
// a static Token.
OAuthManager *oauth.Manager
// OAuthScopes are the scopes requested during OAuth login. They double as
// the scope set for tool filtering: tools requiring a scope outside this set
// are hidden. The default set is the full supported list, which hides
// nothing; an explicit, narrower list filters accordingly.
OAuthScopes []string
}
// RunStdioServer is not concurrent safe.
func RunStdioServer(cfg StdioServerConfig) error {
// OAuth login and a static token are mutually exclusive: they would
// disagree on how the token is sourced (lazy provider vs. static) and on
// scope filtering, so reject the ambiguous combination up front.
if cfg.OAuthManager != nil && cfg.Token != "" {
return fmt.Errorf("OAuthManager and a static Token are mutually exclusive: provide one or the other")
}
// 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, "readOnly", cfg.ReadOnly, "lockdownEnabled", cfg.LockdownMode)
// Determine the scope set used to filter tools. Classic PATs expose their
// granted scopes via the API; OAuth uses the requested scopes (the default
// set hides nothing, a narrower explicit set filters accordingly). Other
// token types don't advertise scopes, so filtering is skipped.
var tokenScopes []string
switch {
case 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)
}
case cfg.OAuthManager != nil:
tokenScopes = cfg.OAuthScopes
logger.Info("using requested OAuth scopes for tool filtering", "scopes", tokenScopes)
default:
logger.Debug("skipping scope filtering for non-PAT token")
}
// For OAuth, the token is resolved lazily: empty until the user authorizes
// on the first tool call, then refreshed for the rest of the session.
var tokenProvider func() string
if cfg.OAuthManager != nil {
tokenProvider = cfg.OAuthManager.AccessToken
}
ghServer, err := NewStdioMCPServer(ctx, github.MCPServerConfig{
Version: cfg.Version,
Host: cfg.Host,
Token: cfg.Token,
EnabledToolsets: cfg.EnabledToolsets,
EnabledTools: cfg.EnabledTools,
EnabledFeatures: cfg.EnabledFeatures,
ReadOnly: cfg.ReadOnly,
Translator: t,
ContentWindowSize: cfg.ContentWindowSize,
LockdownMode: cfg.LockdownMode,
InsidersMode: cfg.InsidersMode,
ExcludeTools: cfg.ExcludeTools,
Logger: logger,
RepoAccessTTL: cfg.RepoAccessCacheTTL,
TokenScopes: tokenScopes,
TokenProvider: tokenProvider,
})
if err != nil {
return fmt.Errorf("failed to create MCP server: %w", err)
}
// With OAuth, intercept tool calls to run the authorization flow on first
// use, before the handler tries to call GitHub with an empty token.
if cfg.OAuthManager != nil {
ghServer.AddReceivingMiddleware(createOAuthMiddleware(cfg.OAuthManager, logger))
}
if cfg.ExportTranslations {
// Once server is initialized, all translations are loaded
dumpTranslations()
}
// Start listening for messages
errC := make(chan error, 1)
go func() {
var in io.ReadCloser
var out io.WriteCloser
in = os.Stdin
out = os.Stdout
if cfg.EnableCommandLogging {
loggedIO := mcplog.NewIOLogger(in, out, logger)
in, out = loggedIO, loggedIO
}
// enable GitHub errors in the context
ctx := errors.ContextWithGitHubErrors(ctx)
errC <- ghServer.Run(ctx, &mcp.IOTransport{Reader: in, Writer: out})
}()
// Output github-mcp-server string
_, _ = fmt.Fprintf(os.Stderr, "GitHub MCP Server running on stdio\n")
// Wait for shutdown signal
select {
case <-ctx.Done():
logger.Info("shutting down server", "signal", "context done")
case err := <-errC:
if err != nil {
logger.Error("error running server", "error", err)
return fmt.Errorf("error running server: %w", err)
}
}
return nil
}
// createFeatureChecker returns a FeatureFlagChecker that resolves features
// using the centralized ResolveFeatureFlags function. For the local server,
// features are resolved once at startup from --features CLI flag and insiders mode.
func createFeatureChecker(enabledFeatures []string, insidersMode bool) inventory.FeatureFlagChecker {
featureSet := github.ResolveFeatureFlags(enabledFeatures, insidersMode)
return func(_ context.Context, flagName string) (bool, error) {
return featureSet[flagName], nil
}
}
func addUserAgentsMiddleware(cfg github.MCPServerConfig, restUATransp *transport.UserAgentTransport, 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" {
return next(ctx, method, request)
}
initializeRequest, ok := request.(*mcp.InitializeRequest)
if !ok {
return next(ctx, method, request)
}
message := initializeRequest
userAgent := fmt.Sprintf(
"github-mcp-server/%s (%s/%s)",
cfg.Version,
message.Params.ClientInfo.Name,
message.Params.ClientInfo.Version,
)
if cfg.InsidersMode {
userAgent += " (insiders)"
}
restUATransp.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)
}
+1
View File
@@ -0,0 +1 @@
package ghmcp
+218
View File
@@ -0,0 +1,218 @@
// githubv4mock package provides a mock GraphQL server used for testing queries produced via
// shurcooL/githubv4 or shurcooL/graphql modules.
package githubv4mock
import (
"encoding/json"
"fmt"
"io"
"net/http"
)
type Matcher struct {
Request string
Variables map[string]any
Response GQLResponse
}
// NewQueryMatcher constructs a new matcher for the provided query and variables.
// If the provided query is a string, it will be used-as-is, otherwise it will be
// converted to a string using the constructQuery function taken from shurcooL/graphql.
func NewQueryMatcher(query any, variables map[string]any, response GQLResponse) Matcher {
queryString, ok := query.(string)
if !ok {
queryString = constructQuery(query, variables)
}
return Matcher{
Request: queryString,
Variables: variables,
Response: response,
}
}
// NewMutationMatcher constructs a new matcher for the provided mutation and variables.
// If the provided mutation is a string, it will be used-as-is, otherwise it will be
// converted to a string using the constructMutation function taken from shurcooL/graphql.
//
// The input parameter is a special form of variable, matching the usage in shurcooL/githubv4. It will be added
// to the query as a variable called `input`. Furthermore, it will be converted to a map[string]any
// to be used for later equality comparison, as when the http handler is called, the request body will no longer
// contain the input struct type information.
func NewMutationMatcher(mutation any, input any, variables map[string]any, response GQLResponse) Matcher {
mutationString, ok := mutation.(string)
if !ok {
// Matching shurcooL/githubv4 mutation behaviour found in https://github.com/shurcooL/githubv4/blob/48295856cce734663ddbd790ff54800f784f3193/githubv4.go#L45-L56
if variables == nil {
variables = map[string]any{"input": input}
} else {
variables["input"] = input
}
mutationString = constructMutation(mutation, variables)
m, _ := githubv4InputStructToMap(input)
variables["input"] = m
}
return Matcher{
Request: mutationString,
Variables: variables,
Response: response,
}
}
type GQLResponse struct {
Data map[string]any `json:"data"`
Errors []struct {
Message string `json:"message"`
} `json:"errors,omitempty"`
}
// DataResponse is the happy path response constructor for a mocked GraphQL request.
func DataResponse(data map[string]any) GQLResponse {
return GQLResponse{
Data: data,
}
}
// ErrorResponse is the unhappy path response constructor for a mocked GraphQL request.\
// Note that for the moment it is only possible to return a single error message.
func ErrorResponse(errorMsg string) GQLResponse {
return GQLResponse{
Errors: []struct {
Message string `json:"message"`
}{
{
Message: errorMsg,
},
},
}
}
// githubv4InputStructToMap converts a struct to a map[string]any, it uses JSON marshalling rather than reflection
// to do so, because the json struct tags are used in the real implementation to produce the variable key names,
// and we need to ensure that when variable matching occurs in the http handler, the keys correctly match.
func githubv4InputStructToMap(s any) (map[string]any, error) {
jsonBytes, err := json.Marshal(s)
if err != nil {
return nil, err
}
var result map[string]any
err = json.Unmarshal(jsonBytes, &result)
return result, err
}
// NewMockedHTTPClient creates a new HTTP client that registers a handler for /graphql POST requests.
// For each request, an attempt will be be made to match the request body against the provided matchers.
// If a match is found, the corresponding response will be returned with StatusOK.
//
// Note that query and variable matching can be slightly fickle. The client expects an EXACT match on the query,
// which in most cases will have been constructed from a type with graphql tags. The query construction code in
// shurcooL/githubv4 uses the field types to derive the query string, thus a go string is not the same as a graphql.ID,
// even though `type ID string`. It is therefore expected that matching variables have the right type for example:
//
// githubv4mock.NewQueryMatcher(
// struct {
// Repository struct {
// PullRequest struct {
// ID githubv4.ID
// } `graphql:"pullRequest(number: $prNum)"`
// } `graphql:"repository(owner: $owner, name: $repo)"`
// }{},
// map[string]any{
// "owner": githubv4.String("owner"),
// "repo": githubv4.String("repo"),
// "prNum": githubv4.Int(42),
// },
// githubv4mock.DataResponse(
// map[string]any{
// "repository": map[string]any{
// "pullRequest": map[string]any{
// "id": "PR_kwDODKw3uc6WYN1T",
// },
// },
// },
// ),
// )
//
// To aid in variable equality checks, values are considered equal if they approximate to the same type. This is
// required because when the http handler is called, the request body no longer has the type information. This manifests
// particularly when using the githubv4.Input types which have type deffed fields in their structs. For example:
//
// type CloseIssueInput struct {
// IssueID ID `json:"issueId"`
// StateReason *IssueClosedStateReason `json:"stateReason,omitempty"`
// }
//
// This client does not currently provide a mechanism for out-of-band errors e.g. returning a 500,
// and errors are constrained to GQL errors returned in the response body with a 200 status code.
func NewMockedHTTPClient(ms ...Matcher) *http.Client {
matchers := make(map[string]Matcher, len(ms))
for _, m := range ms {
matchers[m.Request] = m
}
mux := http.NewServeMux()
mux.HandleFunc("/graphql", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
gqlRequest, err := parseBody(r.Body)
if err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
defer func() { _ = r.Body.Close() }()
matcher, ok := matchers[gqlRequest.Query]
if !ok {
http.Error(w, fmt.Sprintf("no matcher found for query %s", gqlRequest.Query), http.StatusNotFound)
return
}
if len(gqlRequest.Variables) > 0 {
if len(gqlRequest.Variables) != len(matcher.Variables) {
http.Error(w, "variables do not have the same length", http.StatusBadRequest)
return
}
for k, v := range matcher.Variables {
if !objectsAreEqualValues(v, gqlRequest.Variables[k]) {
http.Error(w, "variable does not match", http.StatusBadRequest)
return
}
}
}
responseBody, err := json.Marshal(matcher.Response)
if err != nil {
http.Error(w, "error marshalling response", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(responseBody)
})
return &http.Client{Transport: &localRoundTripper{
handler: mux,
}}
}
type gqlRequest struct {
Query string `json:"query"`
Variables map[string]any `json:"variables,omitempty"`
}
func parseBody(r io.Reader) (gqlRequest, error) {
var req gqlRequest
err := json.NewDecoder(r).Decode(&req)
return req, err
}
func Ptr[T any](v T) *T { return &v }
@@ -0,0 +1,44 @@
// Ths contents of this file are taken from https://github.com/shurcooL/graphql/blob/ed46e5a4646634fc16cb07c3b8db389542cc8847/graphql_test.go#L155-L165
// because they are not exported by the module, and we would like to use them in building the githubv4mock test utility.
//
// The original license, copied from https://github.com/shurcooL/graphql/blob/ed46e5a4646634fc16cb07c3b8db389542cc8847/LICENSE
//
// MIT License
// Copyright (c) 2017 Dmitri Shuralyov
// 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.
package githubv4mock
import (
"net/http"
"net/http/httptest"
)
// localRoundTripper is an http.RoundTripper that executes HTTP transactions
// by using handler directly, instead of going over an HTTP connection.
type localRoundTripper struct {
handler http.Handler
}
func (l localRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
w := httptest.NewRecorder()
l.handler.ServeHTTP(w, req)
return w.Result(), nil
}
@@ -0,0 +1,113 @@
// The contents of this file are taken from https://github.com/stretchr/testify/blob/016e2e9c269209287f33ec203f340a9a723fe22c/assert/assertions.go#L166
// because I do not want to take a dependency on the entire testify module just to use this equality check.
//
// There is a modification in objectsAreEqual to check that typed nils are equal, even if their types are different.
//
// The original license, copied from https://github.com/stretchr/testify/blob/016e2e9c269209287f33ec203f340a9a723fe22c/LICENSE
//
// MIT License
//
// Copyright (c) 2012-2020 Mat Ryer, Tyler Bunnell and contributors.
// 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.
package githubv4mock
import (
"bytes"
"reflect"
)
func objectsAreEqualValues(expected, actual any) bool {
if objectsAreEqual(expected, actual) {
return true
}
expectedValue := reflect.ValueOf(expected)
actualValue := reflect.ValueOf(actual)
if !expectedValue.IsValid() || !actualValue.IsValid() {
return false
}
expectedType := expectedValue.Type()
actualType := actualValue.Type()
if !expectedType.ConvertibleTo(actualType) {
return false
}
if !isNumericType(expectedType) || !isNumericType(actualType) {
// Attempt comparison after type conversion
return reflect.DeepEqual(
expectedValue.Convert(actualType).Interface(), actual,
)
}
// If BOTH values are numeric, there are chances of false positives due
// to overflow or underflow. So, we need to make sure to always convert
// the smaller type to a larger type before comparing.
if expectedType.Size() >= actualType.Size() {
return actualValue.Convert(expectedType).Interface() == expected
}
return expectedValue.Convert(actualType).Interface() == actual
}
// objectsAreEqual determines if two objects are considered equal.
//
// This function does no assertion of any kind.
func objectsAreEqual(expected, actual any) bool {
// There is a modification in objectsAreEqual to check that typed nils are equal, even if their types are different.
// This is required because when a nil is provided as a variable, the type is not known.
if isNil(expected) && isNil(actual) {
return true
}
exp, ok := expected.([]byte)
if !ok {
return reflect.DeepEqual(expected, actual)
}
act, ok := actual.([]byte)
if !ok {
return false
}
if exp == nil || act == nil {
return exp == nil && act == nil
}
return bytes.Equal(exp, act)
}
// isNumericType returns true if the type is one of:
// int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64,
// float32, float64, complex64, complex128
func isNumericType(t reflect.Type) bool {
return t.Kind() >= reflect.Int && t.Kind() <= reflect.Complex128
}
func isNil(i any) bool {
if i == nil {
return true
}
v := reflect.ValueOf(i)
switch v.Kind() {
case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice:
return v.IsNil()
default:
return false
}
}
@@ -0,0 +1,73 @@
// The contents of this file are taken from https://github.com/stretchr/testify/blob/016e2e9c269209287f33ec203f340a9a723fe22c/assert/assertions_test.go#L140-L174
//
// There is a modification to test objectsAreEqualValues to check that typed nils are equal, even if their types are different.
// The original license, copied from https://github.com/stretchr/testify/blob/016e2e9c269209287f33ec203f340a9a723fe22c/LICENSE
//
// MIT License
//
// Copyright (c) 2012-2020 Mat Ryer, Tyler Bunnell and contributors.
// 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.
package githubv4mock
import (
"fmt"
"math"
"testing"
"time"
)
func TestObjectsAreEqualValues(t *testing.T) {
now := time.Now()
cases := []struct {
expected interface{}
actual interface{}
result bool
}{
{uint32(10), int32(10), true},
{0, nil, false},
{nil, 0, false},
{now, now.In(time.Local), false}, // should not be time zone independent
{int(270), int8(14), false}, // should handle overflow/underflow
{int8(14), int(270), false},
{[]int{270, 270}, []int8{14, 14}, false},
{complex128(1e+100 + 1e+100i), complex64(complex(math.Inf(0), math.Inf(0))), false},
{complex64(complex(math.Inf(0), math.Inf(0))), complex128(1e+100 + 1e+100i), false},
{complex128(1e+100 + 1e+100i), 270, false},
{270, complex128(1e+100 + 1e+100i), false},
{complex128(1e+100 + 1e+100i), 3.14, false},
{3.14, complex128(1e+100 + 1e+100i), false},
{complex128(1e+10 + 1e+10i), complex64(1e+10 + 1e+10i), true},
{complex64(1e+10 + 1e+10i), complex128(1e+10 + 1e+10i), true},
{(*string)(nil), nil, true}, // typed nil vs untyped nil
{(*string)(nil), (*int)(nil), true}, // different typed nils
}
for _, c := range cases {
t.Run(fmt.Sprintf("ObjectsAreEqualValues(%#v, %#v)", c.expected, c.actual), func(t *testing.T) {
res := objectsAreEqualValues(c.expected, c.actual)
if res != c.result {
t.Errorf("ObjectsAreEqualValues(%#v, %#v) should return %#v", c.expected, c.actual, c.result)
}
})
}
}
+157
View File
@@ -0,0 +1,157 @@
// Ths contents of this file are taken from https://github.com/shurcooL/graphql/blob/ed46e5a4646634fc16cb07c3b8db389542cc8847/query.go
// because they are not exported by the module, and we would like to use them in building the githubv4mock test utility.
//
// The original license, copied from https://github.com/shurcooL/graphql/blob/ed46e5a4646634fc16cb07c3b8db389542cc8847/LICENSE
//
// MIT License
// Copyright (c) 2017 Dmitri Shuralyov
// 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.
package githubv4mock
import (
"bytes"
"encoding/json"
"io"
"reflect"
"sort"
"github.com/shurcooL/graphql/ident"
)
func constructQuery(v any, variables map[string]any) string {
query := query(v)
if len(variables) > 0 {
return "query(" + queryArguments(variables) + ")" + query
}
return query
}
func constructMutation(v any, variables map[string]any) string {
query := query(v)
if len(variables) > 0 {
return "mutation(" + queryArguments(variables) + ")" + query
}
return "mutation" + query
}
// queryArguments constructs a minified arguments string for variables.
//
// E.g., map[string]any{"a": Int(123), "b": NewBoolean(true)} -> "$a:Int!$b:Boolean".
func queryArguments(variables map[string]any) string {
// Sort keys in order to produce deterministic output for testing purposes.
// TODO: If tests can be made to work with non-deterministic output, then no need to sort.
keys := make([]string, 0, len(variables))
for k := range variables {
keys = append(keys, k)
}
sort.Strings(keys)
var buf bytes.Buffer
for _, k := range keys {
_, _ = io.WriteString(&buf, "$")
_, _ = io.WriteString(&buf, k)
_, _ = io.WriteString(&buf, ":")
writeArgumentType(&buf, reflect.TypeOf(variables[k]), true)
// Don't insert a comma here.
// Commas in GraphQL are insignificant, and we want minified output.
// See https://spec.graphql.org/October2021/#sec-Insignificant-Commas.
}
return buf.String()
}
// writeArgumentType writes a minified GraphQL type for t to w.
// value indicates whether t is a value (required) type or pointer (optional) type.
// If value is true, then "!" is written at the end of t.
func writeArgumentType(w io.Writer, t reflect.Type, value bool) {
if t.Kind() == reflect.Ptr {
// Pointer is an optional type, so no "!" at the end of the pointer's underlying type.
writeArgumentType(w, t.Elem(), false)
return
}
switch t.Kind() {
case reflect.Slice, reflect.Array:
// List. E.g., "[Int]".
_, _ = io.WriteString(w, "[")
writeArgumentType(w, t.Elem(), true)
_, _ = io.WriteString(w, "]")
default:
// Named type. E.g., "Int".
name := t.Name()
if name == "string" { // HACK: Workaround for https://github.com/shurcooL/githubv4/issues/12.
name = "ID"
}
_, _ = io.WriteString(w, name)
}
if value {
// Value is a required type, so add "!" to the end.
_, _ = io.WriteString(w, "!")
}
}
// query uses writeQuery to recursively construct
// a minified query string from the provided struct v.
//
// E.g., struct{Foo Int, BarBaz *Boolean} -> "{foo,barBaz}".
func query(v any) string {
var buf bytes.Buffer
writeQuery(&buf, reflect.TypeOf(v), false)
return buf.String()
}
// writeQuery writes a minified query for t to w.
// If inline is true, the struct fields of t are inlined into parent struct.
func writeQuery(w io.Writer, t reflect.Type, inline bool) {
switch t.Kind() {
case reflect.Ptr, reflect.Slice:
writeQuery(w, t.Elem(), false)
case reflect.Struct:
// If the type implements json.Unmarshaler, it's a scalar. Don't expand it.
if reflect.PointerTo(t).Implements(jsonUnmarshaler) {
return
}
if !inline {
_, _ = io.WriteString(w, "{")
}
for i := 0; i < t.NumField(); i++ {
if i != 0 {
_, _ = io.WriteString(w, ",")
}
f := t.Field(i)
value, ok := f.Tag.Lookup("graphql")
inlineField := f.Anonymous && !ok
if !inlineField {
if ok {
_, _ = io.WriteString(w, value)
} else {
_, _ = io.WriteString(w, ident.ParseMixedCaps(f.Name).ToLowerCamelCase())
}
}
writeQuery(w, f.Type, inlineField)
}
if !inline {
_, _ = io.WriteString(w, "}")
}
}
}
var jsonUnmarshaler = reflect.TypeOf((*json.Unmarshaler)(nil)).Elem()
+157
View File
@@ -0,0 +1,157 @@
package oauth
import (
"context"
"embed"
"fmt"
"html/template"
"net"
"net/http"
"time"
)
//go:embed templates/*.html
var templateFS embed.FS
var (
errorTemplate = template.Must(template.ParseFS(templateFS, "templates/error.html"))
successTemplate = template.Must(template.ParseFS(templateFS, "templates/success.html"))
)
// callbackResult is delivered by the callback server once the browser redirect
// arrives. Exactly one of code or err is set.
type callbackResult struct {
code string
err error
}
// callbackServer is a short-lived local HTTP server that captures the
// authorization code from the OAuth redirect.
type callbackServer struct {
server *http.Server
listener net.Listener
redirect string
results chan callbackResult
}
// listenCallback binds the local callback listener.
//
// It binds to loopback (127.0.0.1) by default so the callback server is never
// exposed on other interfaces. bindAll is set only inside a container, where
// Docker's published-port DNAT delivers traffic to the container's eth0 rather
// than to loopback; host-side exposure is still constrained by the publish
// (e.g. -p 127.0.0.1:8085:8085). A native run — even with a fixed port — stays
// on loopback.
func listenCallback(port int, bindAll bool) (net.Listener, error) {
host := "127.0.0.1"
if bindAll {
host = "0.0.0.0"
}
addr := fmt.Sprintf("%s:%d", host, port)
listener, err := net.Listen("tcp", addr)
if err != nil {
return nil, fmt.Errorf("starting callback listener on %s: %w", addr, err)
}
return listener, nil
}
// newCallbackServer starts a callback server on listener that validates state
// and reports the result on a buffered channel. The redirect URI always uses
// localhost so it matches the value registered on the OAuth/GitHub App.
func newCallbackServer(listener net.Listener, expectedState string) *callbackServer {
cs := &callbackServer{
server: &http.Server{ReadHeaderTimeout: 10 * time.Second}, // ReadHeaderTimeout guards against Slowloris.
listener: listener,
redirect: fmt.Sprintf("http://localhost:%d/callback", listener.Addr().(*net.TCPAddr).Port),
results: make(chan callbackResult, 1),
}
cs.server.Handler = cs.handler(expectedState)
go func() {
if err := cs.server.Serve(listener); err != nil && err != http.ErrServerClosed {
cs.report(callbackResult{err: fmt.Errorf("callback server: %w", err)})
}
}()
return cs
}
// handler renders the callback endpoint. It reports the outcome exactly once and
// always shows the user a friendly page.
func (cs *callbackServer) handler(expectedState string) http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
if errCode := q.Get("error"); errCode != "" {
msg := errCode
if desc := q.Get("error_description"); desc != "" {
msg = fmt.Sprintf("%s: %s", errCode, desc)
}
cs.report(callbackResult{err: fmt.Errorf("authorization failed: %s", msg)})
renderError(w, msg)
return
}
if q.Get("state") != expectedState {
cs.report(callbackResult{err: fmt.Errorf("state mismatch (possible CSRF)")})
renderError(w, "state mismatch")
return
}
code := q.Get("code")
if code == "" {
cs.report(callbackResult{err: fmt.Errorf("no authorization code in callback")})
renderError(w, "no authorization code received")
return
}
cs.report(callbackResult{code: code})
renderSuccess(w)
})
return mux
}
// report delivers the first outcome and drops later ones (the channel is
// buffered for one; subsequent redirect retries must not block the handler).
func (cs *callbackServer) report(res callbackResult) {
select {
case cs.results <- res:
default:
}
}
// wait blocks for the callback outcome or ctx cancellation, then shuts the
// server down. It is safe to call once per server.
func (cs *callbackServer) wait(ctx context.Context) (string, error) {
defer cs.close()
select {
case res := <-cs.results:
return res.code, res.err
case <-ctx.Done():
return "", ctx.Err()
}
}
func (cs *callbackServer) close() {
shutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_ = cs.server.Shutdown(shutdownCtx)
_ = cs.listener.Close()
}
func renderSuccess(w http.ResponseWriter) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := successTemplate.Execute(w, nil); err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
}
}
// renderError shows the failure page. html/template auto-escapes msg, so a
// hostile error_description cannot inject markup.
func renderError(w http.ResponseWriter, msg string) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := errorTemplate.Execute(w, struct{ ErrorMessage string }{ErrorMessage: msg}); err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
}
}
+92
View File
@@ -0,0 +1,92 @@
package oauth
import (
"net"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// serveCallback drives the callback handler with the given query string and
// returns the recorded response and the single reported result.
func serveCallback(t *testing.T, expectedState, query string) (*httptest.ResponseRecorder, callbackResult) {
t.Helper()
cs := &callbackServer{results: make(chan callbackResult, 1)}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/callback?"+query, nil)
cs.handler(expectedState).ServeHTTP(rec, req)
select {
case res := <-cs.results:
return rec, res
default:
t.Fatal("handler did not report a result")
return nil, callbackResult{}
}
}
func TestCallbackHandlerSuccess(t *testing.T) {
rec, res := serveCallback(t, "state123", "code=the-code&state=state123")
require.NoError(t, res.err)
assert.Equal(t, "the-code", res.code)
assert.Equal(t, http.StatusOK, rec.Code)
assert.Contains(t, rec.Body.String(), "Authorization Successful")
}
func TestCallbackHandlerStateMismatch(t *testing.T) {
rec, res := serveCallback(t, "expected", "code=the-code&state=attacker")
require.Error(t, res.err)
assert.Empty(t, res.code)
assert.Contains(t, res.err.Error(), "state mismatch")
assert.Contains(t, rec.Body.String(), "state mismatch")
}
func TestCallbackHandlerMissingCode(t *testing.T) {
_, res := serveCallback(t, "state123", "state=state123")
require.Error(t, res.err)
assert.Contains(t, res.err.Error(), "no authorization code")
}
func TestCallbackHandlerOAuthError(t *testing.T) {
_, res := serveCallback(t, "state123", "error=access_denied&error_description=user+said+no")
require.Error(t, res.err)
assert.Contains(t, res.err.Error(), "access_denied")
assert.Contains(t, res.err.Error(), "user said no")
}
func TestCallbackHandlerEscapesError(t *testing.T) {
rec, _ := serveCallback(t, "state123", "error=evil&error_description=%3Cscript%3Ealert(1)%3C%2Fscript%3E")
body := rec.Body.String()
assert.NotContains(t, body, "<script>", "error message must be HTML-escaped")
assert.Contains(t, body, "&lt;script&gt;")
}
func TestListenCallbackRandomPortIsLoopback(t *testing.T) {
listener, err := listenCallback(0, false)
require.NoError(t, err)
defer listener.Close()
addr, ok := listener.Addr().(*net.TCPAddr)
require.True(t, ok)
assert.True(t, addr.IP.IsLoopback(), "default bind must be loopback only, got %s", addr.IP)
assert.NotZero(t, addr.Port)
}
func TestListenCallbackBindAllForContainer(t *testing.T) {
listener, err := listenCallback(0, true)
require.NoError(t, err)
defer listener.Close()
addr, ok := listener.Addr().(*net.TCPAddr)
require.True(t, ok)
assert.True(t, addr.IP.IsUnspecified(), "bindAll must bind all interfaces, got %s", addr.IP)
}
+70
View File
@@ -0,0 +1,70 @@
package oauth
import (
"errors"
"fmt"
"io"
"os"
"os/exec"
"runtime"
"strings"
)
// errNoDisplay reports that the host has no display server, so no browser can be
// launched. It is a definitive headless signal (unlike a generic launch error),
// which lets the flow prefer device authorization — the only channel reachable
// from a browser on another machine (e.g. a remote SSH session).
var errNoDisplay = errors.New("no display server detected")
// openBrowser tries to open url in the user's default browser. It returns an
// error when no browser can plausibly be launched so the caller can fall back
// to elicitation. On Linux it treats a headless session (no display server) as
// unopenable, which is the common case for SSH and containers.
func openBrowser(url string) error {
var cmd *exec.Cmd
switch runtime.GOOS {
case "linux":
if os.Getenv("DISPLAY") == "" && os.Getenv("WAYLAND_DISPLAY") == "" {
return errNoDisplay
}
cmd = exec.Command("xdg-open", url)
case "darwin":
cmd = exec.Command("open", url)
case "windows":
cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url)
default:
return fmt.Errorf("unsupported platform: %s", runtime.GOOS)
}
cmd.Stdout = io.Discard
cmd.Stderr = io.Discard
if err := cmd.Start(); err != nil {
return err
}
// The launcher (xdg-open/open/rundll32) exits as soon as it hands off to the
// browser. Reap it asynchronously so it does not linger as a zombie for the
// lifetime of this long-running server.
go func() { _ = cmd.Wait() }()
return nil
}
// isRunningInDocker reports whether the process is running inside a Docker (or
// containerd) container. Detection relies on Linux-specific paths and is always
// false elsewhere. It is used only to skip a PKCE flow that cannot work: a
// random callback port inside a container cannot be reached from the host
// browser, so we go straight to device flow in that case.
func isRunningInDocker() bool {
if runtime.GOOS != "linux" {
return false
}
if _, err := os.Stat("/.dockerenv"); err == nil {
return true
}
if data, err := os.ReadFile("/proc/1/cgroup"); err == nil {
s := string(data)
if strings.Contains(s, "docker") || strings.Contains(s, "containerd") {
return true
}
}
return false
}
+220
View File
@@ -0,0 +1,220 @@
package oauth
import (
"context"
"errors"
"fmt"
"time"
"golang.org/x/oauth2"
)
// deviceAuthTimeout bounds the synchronous device-code request made while
// preparing the device flow (before any waiting on the user).
const deviceAuthTimeout = 30 * time.Second
// errCallbackBind marks a failure to bind the local OAuth callback listener, so
// begin can treat a busy fixed port as fatal without mislabeling unrelated
// errors (e.g. a failure to generate the state parameter) as a port conflict.
var errCallbackBind = errors.New("OAuth callback listener could not bind")
// flowPlan is a prepared authorization flow ready to run in the background.
type flowPlan struct {
// run performs the blocking part of the flow (await callback + exchange, or
// poll the device endpoint) and returns the token.
run func(context.Context) (*oauth2.Token, error)
// display, if set, presents the prompt to the user via the Prompter and
// blocks until they act. ErrPromptDeclined (the user said no) or any other
// error aborts the flow, except ErrPromptUnavailable, which degrades to
// fallback when that is set.
display func(context.Context) error
// fallback, if set alongside display, is the manual user action to surface
// when the display prompt cannot be delivered (ErrPromptUnavailable). It lets
// a runtime elicitation failure degrade to the manual channel — keeping the
// background flow alive — instead of aborting.
fallback *UserAction
// userAction, if set, indicates the last-resort channel: the caller must
// surface it and the user retries after authorizing out of band.
userAction *UserAction
}
// begin selects and prepares the appropriate flow. PKCE is preferred for its
// stronger security; device flow is the fallback. A random callback port inside
// Docker cannot be reached from the host browser, so that combination goes
// straight to device flow.
func (m *Manager) begin(prompter Prompter) (*flowPlan, error) {
canPKCE := m.config.CallbackPort != 0 || !m.inDocker()
if canPKCE {
plan, err := m.beginPKCE(prompter)
if err == nil {
return plan, nil
}
// A fixed callback port that won't bind is fatal, not a cue to downgrade.
// The port was chosen deliberately (and registered with the OAuth app), so
// a bind failure means another process holds it — possibly one positioned
// to intercept the authorization redirect. Silently switching to device
// flow would mask that, so stop and make the user resolve it. Only genuine
// bind failures qualify; other errors fall through to device flow.
if m.config.CallbackPort != 0 && errors.Is(err, errCallbackBind) {
return nil, fmt.Errorf("OAuth callback port %d is not available; another process may be using it — free the port or set a different --oauth-callback-port: %w", m.config.CallbackPort, err)
}
m.logger.Info("PKCE flow unavailable, falling back to device flow", "reason", err)
} else {
m.logger.Info("no callback port inside container; using device flow")
}
return m.beginDevice(prompter)
}
// beginPKCE prepares the authorization-code + PKCE flow. It binds the callback
// server and selects the most secure available display channel: browser
// auto-open, then URL elicitation, then a tool-response message. On a headless
// host with a random callback port it diverts to device flow, whose redirect
// does not depend on reaching this machine's localhost.
func (m *Manager) beginPKCE(prompter Prompter) (*flowPlan, error) {
state, err := randomState()
if err != nil {
return nil, err
}
verifier := oauth2.GenerateVerifier()
// Bind to all interfaces only inside a container, where the published port
// is delivered via eth0 rather than loopback. Native runs stay on loopback.
listener, err := listenCallback(m.config.CallbackPort, m.inDocker())
if err != nil {
return nil, fmt.Errorf("%w: %w", errCallbackBind, err)
}
if m.inDocker() {
// Inside a container the callback binds all interfaces so the published
// port is reachable, which also exposes it to the container network.
// Publishing to loopback only (e.g. -p 127.0.0.1:%d:%d) keeps the
// authorization code off the network.
m.logger.Warn(fmt.Sprintf("OAuth callback is listening on all container interfaces; publish it to loopback only (e.g. -p 127.0.0.1:%d:%d) so the authorization code is not exposed on your network", m.config.CallbackPort, m.config.CallbackPort))
}
cs := newCallbackServer(listener, state)
oc := m.oauth2Config(cs.redirect)
authURL := oc.AuthCodeURL(state, oauth2.S256ChallengeOption(verifier))
run := func(ctx context.Context) (*oauth2.Token, error) {
code, err := cs.wait(ctx)
if err != nil {
return nil, err
}
tok, err := oc.Exchange(ctx, code, oauth2.VerifierOption(verifier))
if err != nil {
return nil, fmt.Errorf("exchanging authorization code: %w", err)
}
return tok, nil
}
browserErr := m.openURL(authURL)
switch {
case browserErr == nil:
m.logger.Info("opened browser for GitHub authorization")
return &flowPlan{run: run}, nil
case errors.Is(browserErr, errNoDisplay) && m.config.CallbackPort == 0:
// Headless host with a random callback port: every PKCE channel ends in a
// redirect to this machine's localhost, which a browser on another machine
// (e.g. a remote SSH client) cannot reach — so even URL elicitation would
// dead-end. Device flow is the only channel reachable from elsewhere, so
// prefer it when the app supports it; otherwise fall through to the manual
// authorization URL below for a same-machine browser.
plan, deviceErr := m.beginDevice(prompter)
if deviceErr == nil {
cs.close()
m.logger.Info("no display server; using device flow")
return plan, nil
}
m.logger.Debug("device flow unavailable on headless host; offering manual authorization URL", "reason", deviceErr)
default:
m.logger.Debug("browser auto-open unavailable", "reason", browserErr)
}
// The manual instructions double as the fallback if a chosen display channel
// turns out to be undeliverable at runtime, so build them once here.
manual := &UserAction{
URL: authURL,
Message: fmt.Sprintf(
"To authorize the GitHub MCP Server, open this URL in your browser:\n\n%s\n\nAfter authorizing, retry your request.\n\n%s",
authURL, securityAdvisory,
),
}
if canPromptURL(prompter) {
display := func(ctx context.Context) error {
return prompter.PromptURL(ctx, Prompt{
Message: "Authorize the GitHub MCP Server in your browser to continue.",
URL: authURL,
})
}
return &flowPlan{run: run, display: display, fallback: manual}, nil
}
return &flowPlan{run: run, userAction: manual}, nil
}
// beginDevice prepares the device authorization flow. It requests a device code
// up front (so the code can be displayed) and selects a display channel:
// URL elicitation, then form elicitation, then a tool-response message.
func (m *Manager) beginDevice(prompter Prompter) (*flowPlan, error) {
oc := m.oauth2Config("")
ctx, cancel := context.WithTimeout(context.Background(), deviceAuthTimeout)
defer cancel()
da, err := oc.DeviceAuth(ctx)
if err != nil {
return nil, fmt.Errorf("requesting device code: %w", err)
}
run := func(ctx context.Context) (*oauth2.Token, error) {
tok, err := oc.DeviceAccessToken(ctx, da)
if err != nil {
return nil, fmt.Errorf("awaiting device authorization: %w", err)
}
return tok, nil
}
// As with PKCE, the manual instructions double as the runtime fallback, so
// build them once and reuse for both display plans and the last resort.
manual := &UserAction{
URL: da.VerificationURI,
UserCode: da.UserCode,
Message: fmt.Sprintf(
"%s\n\nAfter authorizing, retry your request.\n\n%s",
deviceInstruction(da), securityAdvisory,
),
}
if canPromptURL(prompter) {
display := func(ctx context.Context) error {
return prompter.PromptURL(ctx, Prompt{
Message: fmt.Sprintf("Enter code %s to authorize the GitHub MCP Server.", da.UserCode),
URL: da.VerificationURI,
UserCode: da.UserCode,
})
}
return &flowPlan{run: run, display: display, fallback: manual}, nil
}
if canPromptForm(prompter) {
display := func(ctx context.Context) error {
return prompter.PromptForm(ctx, Prompt{
Message: deviceInstruction(da),
URL: da.VerificationURI,
UserCode: da.UserCode,
})
}
return &flowPlan{run: run, display: display, fallback: manual}, nil
}
return &flowPlan{run: run, userAction: manual}, nil
}
// securityAdvisory nudges users on clients without URL elicitation to ask their
// vendor for it, since it keeps the authorization URL out of the model context.
const securityAdvisory = "Note: your MCP client does not appear to support secure URL elicitation. " +
"For improved security, consider asking your agent, CLI, or IDE to add it (for example, by opening an issue)."
func deviceInstruction(da *oauth2.DeviceAuthResponse) string {
return fmt.Sprintf("Visit %s and enter the code %s to authorize the GitHub MCP Server.", da.VerificationURI, da.UserCode)
}
+310
View File
@@ -0,0 +1,310 @@
package oauth
import (
"context"
"errors"
"log/slog"
"net/http"
"os"
"sync"
"time"
"golang.org/x/oauth2"
)
// DefaultAuthTimeout bounds how long a single authorization attempt waits for
// the user to complete the browser or device flow.
const DefaultAuthTimeout = 5 * time.Minute
// tokenRefreshTimeout bounds each background refresh of an expiring token so a
// stalled GitHub token endpoint cannot block a tool call indefinitely.
const tokenRefreshTimeout = 30 * time.Second
// flowStatus tracks the manager's single-flight authorization state.
type flowStatus int
const (
statusIdle flowStatus = iota // no flow running
statusStarting // a flow is being prepared (brief)
statusInProgress // a flow is running on a secure channel; callers may join
statusAwaitingUser // a flow is running but the user must act out-of-band
)
// Outcome reports the result of an authorization attempt that did not
// immediately yield a token.
type Outcome struct {
// UserAction, when non-nil, must be surfaced to the user. The authorization
// flow continues in the background; the user should retry once they have
// completed it.
UserAction *UserAction
}
// UserAction is an instruction for the user to complete authorization out of
// band (the last-resort channel, used when neither a browser nor URL
// elicitation is available).
type UserAction struct {
// Message is ready to display to the user.
Message string
// URL is the authorization URL or device verification URI.
URL string
// UserCode is the device-flow code to enter, if any.
UserCode string
}
// Manager owns the OAuth login flows and the resulting (refreshing) token for a
// single stdio session. It is safe for concurrent use; only one authorization
// flow runs at a time.
type Manager struct {
config Config
refreshConfig *oauth2.Config
logger *slog.Logger
// Test seams, set by NewManager to real implementations.
openURL func(string) error
inDocker func() bool
mu sync.Mutex
source oauth2.TokenSource // refreshing source, set once authorized
status flowStatus
pending *UserAction
done chan struct{}
lastErr error
refreshErrLogged bool // true once a refresh failure has been logged, reset on re-auth
}
// NewManager builds a Manager for the given configuration. A nil logger logs to
// stderr.
func NewManager(cfg Config, logger *slog.Logger) *Manager {
if logger == nil {
logger = slog.New(slog.NewTextHandler(os.Stderr, nil))
}
m := &Manager{
config: cfg,
logger: logger,
openURL: openBrowser,
inDocker: isRunningInDocker,
}
m.refreshConfig = m.oauth2Config("")
return m
}
// AccessToken returns a currently valid access token, refreshing it if needed,
// or "" if the session is not authorized (or a refresh has failed and
// re-authorization is required). It is cheap to call repeatedly: the underlying
// token source caches and only refreshes when the token has expired.
func (m *Manager) AccessToken() string {
m.mu.Lock()
src := m.source
m.mu.Unlock()
if src == nil {
return ""
}
// Refresh (if needed) happens here, off the lock, because ReuseTokenSource may
// make a blocking network call and holding m.mu would serialize every tool call.
tok, err := src.Token()
if err != nil {
// A refresh failure (expired GitHub App refresh token, revoked grant, or a
// network blip) leaves the session unauthorized and forces a re-login.
// Surface it once, otherwise it only manifests as a surprise re-authorization
// prompt. The oauth2 error carries the token endpoint's response, not the
// access or refresh token.
m.mu.Lock()
if !m.refreshErrLogged {
m.refreshErrLogged = true
m.logger.Warn("OAuth token refresh failed; re-authorization required", "error", err)
}
m.mu.Unlock()
return ""
}
if !tok.Valid() {
return ""
}
return tok.AccessToken
}
// HasToken reports whether a valid token is currently available.
func (m *Manager) HasToken() bool {
return m.AccessToken() != ""
}
// Authenticate ensures the session is authorized.
//
// It returns (nil, nil) once a token is available, so the caller may proceed.
// It returns (&Outcome{UserAction}, nil) when the user must complete the flow
// out of band; the flow continues in the background and the caller should show
// the action and have the user retry. It returns (nil, err) on failure.
//
// Only one flow runs at a time. Concurrent callers either join a running secure
// flow, receive the pending user action, or are told to retry shortly.
func (m *Manager) Authenticate(ctx context.Context, prompter Prompter) (*Outcome, error) {
if m.AccessToken() != "" {
return nil, nil
}
m.mu.Lock()
switch m.status {
case statusAwaitingUser:
ua := m.pending
m.mu.Unlock()
return &Outcome{UserAction: ua}, nil
case statusStarting:
m.mu.Unlock()
return &Outcome{UserAction: &UserAction{
Message: "GitHub authorization is already in progress. Please retry your request in a few seconds.",
}}, nil
case statusInProgress:
done := m.done
m.mu.Unlock()
return m.joinWait(ctx, done)
}
// Idle: this call owns the new flow.
m.status = statusStarting
m.lastErr = nil
m.done = make(chan struct{})
done := m.done
m.mu.Unlock()
plan, err := m.begin(prompter)
if err != nil {
m.complete(nil, err)
return nil, err
}
m.mu.Lock()
if plan.userAction != nil {
m.status = statusAwaitingUser
m.pending = plan.userAction
} else {
m.status = statusInProgress
}
m.mu.Unlock()
bgCtx, cancel := context.WithTimeout(context.Background(), DefaultAuthTimeout)
go m.runFlow(bgCtx, cancel, plan)
if plan.userAction != nil {
return &Outcome{UserAction: plan.userAction}, nil
}
return m.joinWait(ctx, done)
}
// runFlow executes a prepared flow in the background and records the result. The
// optional display prompt runs concurrently: a decline (or other failure) aborts
// the flow, while an undeliverable prompt degrades to the manual fallback without
// tearing the flow down, so the user can still authorize out of band.
func (m *Manager) runFlow(ctx context.Context, cancel context.CancelFunc, plan *flowPlan) {
defer cancel()
if plan.display != nil {
go func() {
err := plan.display(ctx)
switch {
case err == nil:
// Prompt shown; the flow completes when the token arrives.
case ctx.Err() != nil:
// The flow is already ending (timed out or cancelled elsewhere),
// so there is nothing to fall back to. Checking this before the
// fallback also prevents misreading a context-cancelled prompt as
// a transport failure.
case errors.Is(err, ErrPromptUnavailable) && plan.fallback != nil:
// The client advertised the capability but could not deliver the
// prompt. Surface the manual instructions instead of failing, and
// keep the background flow alive so the user can still authorize.
m.logger.Debug("authorization prompt undeliverable; falling back to manual instructions", "reason", err)
m.fallBackToUserAction(plan.fallback)
default:
// A user decline (ErrPromptDeclined) or any other prompt failure
// ends the flow.
m.logger.Debug("authorization prompt closed", "reason", err)
cancel()
}
}()
}
tok, err := plan.run(ctx)
m.complete(tok, err)
}
// fallBackToUserAction promotes a running secure flow to the manual user-action
// channel after its prompt could not be delivered. The background flow keeps
// running, so the user can complete authorization out of band and retry. It is a
// no-op if the flow has already resolved.
func (m *Manager) fallBackToUserAction(ua *UserAction) {
m.mu.Lock()
defer m.mu.Unlock()
if m.status != statusInProgress {
return
}
m.status = statusAwaitingUser
m.pending = ua
// Wake any callers joined on this flow so they receive the action, and clear
// done so complete() does not double-close it when run() later finishes.
if m.done != nil {
close(m.done)
m.done = nil
}
}
// complete records the flow result, installing a refreshing token source on
// success, and wakes any joined callers.
func (m *Manager) complete(tok *oauth2.Token, err error) {
m.mu.Lock()
defer m.mu.Unlock()
m.status = statusIdle
m.pending = nil
if err != nil {
m.lastErr = err
m.logger.Debug("oauth flow failed", "error", err)
} else {
m.lastErr = nil
// Config.TokenSource returns a ReuseTokenSource that refreshes expired
// tokens using the refresh token — this is what makes GitHub App
// (expiring) tokens work transparently. The refresh uses a bounded HTTP
// client so a stalled token endpoint can't block a tool call forever.
refreshCtx := context.WithValue(context.Background(), oauth2.HTTPClient, &http.Client{Timeout: tokenRefreshTimeout})
m.source = m.refreshConfig.TokenSource(refreshCtx, tok)
m.refreshErrLogged = false
m.logger.Info("github authorization complete")
}
if m.done != nil {
close(m.done)
m.done = nil
}
}
// joinWait blocks until the running flow finishes or ctx is cancelled. If the
// flow was promoted to the manual channel while waiting (its prompt could not be
// delivered), it returns that user action rather than an error.
func (m *Manager) joinWait(ctx context.Context, done chan struct{}) (*Outcome, error) {
select {
case <-done:
if m.AccessToken() != "" {
return nil, nil
}
m.mu.Lock()
pending := m.pending
err := m.lastErr
m.mu.Unlock()
if pending != nil {
return &Outcome{UserAction: pending}, nil
}
if err != nil {
return nil, err
}
return nil, errors.New("authorization did not complete")
case <-ctx.Done():
return nil, ctx.Err()
}
}
func (m *Manager) oauth2Config(redirectURL string) *oauth2.Config {
return &oauth2.Config{
ClientID: m.config.ClientID,
ClientSecret: m.config.ClientSecret,
RedirectURL: redirectURL,
Scopes: m.config.Scopes,
Endpoint: m.config.Endpoint,
}
}
+321
View File
@@ -0,0 +1,321 @@
package oauth
import (
"context"
"errors"
"net"
"strconv"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// newManager wires a Manager to the fake GitHub server. By default the browser
// auto-opens (driving the callback) and Docker detection is off.
func newManager(t *testing.T, f *fakeGitHub) *Manager {
t.Helper()
cfg := Config{
ClientID: "client-id",
ClientSecret: "client-secret",
Scopes: []string{"repo"},
Endpoint: f.endpoint(),
}
m := NewManager(cfg, testLogger())
m.openURL = browserGet
m.inDocker = func() bool { return false }
return m
}
func TestAuthenticatePKCEViaBrowser(t *testing.T) {
f := newFakeGitHub(t)
m := newManager(t, f)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
out, err := m.Authenticate(ctx, nil)
require.NoError(t, err)
assert.Nil(t, out, "browser flow completes without a user action")
assert.Equal(t, "gho_access", m.AccessToken())
// PKCE must have been exercised end to end.
f.mu.Lock()
defer f.mu.Unlock()
assert.Equal(t, "S256", f.codeChallengeMethod)
assert.NotEmpty(t, f.codeChallenge, "authorize must receive a code_challenge")
assert.NotEmpty(t, f.codeVerifier, "token exchange must send a code_verifier")
assert.Equal(t, []string{"authorization_code"}, f.grants)
}
func TestAuthenticateRefreshesExpiringGitHubAppToken(t *testing.T) {
f := newFakeGitHub(t)
// GitHub App: the initial token expires immediately and carries a refresh
// token, so the very next read must refresh transparently.
f.authToken = "ghu_initial"
f.authRefresh = "ghr_refresh"
f.authExpires = 1
f.refreshToken = "ghu_refreshed"
f.refreshExpires = 3600
m := newManager(t, f)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, err := m.Authenticate(ctx, nil)
require.NoError(t, err)
assert.Equal(t, "ghu_refreshed", m.AccessToken(), "expired token must be refreshed")
assert.Equal(t, []string{"authorization_code", "refresh_token"}, f.recordedGrants())
}
func TestAuthenticateURLElicitation(t *testing.T) {
f := newFakeGitHub(t)
m := newManager(t, f)
m.openURL = func(string) error { return errors.New("no browser") }
prompter := &fakePrompter{
urlCapable: true,
onURL: func(_ context.Context, p Prompt) error {
return browserGet(p.URL) // user opens the URL and authorizes
},
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
out, err := m.Authenticate(ctx, prompter)
require.NoError(t, err)
assert.Nil(t, out)
assert.Equal(t, "gho_access", m.AccessToken())
prompter.mu.Lock()
defer prompter.mu.Unlock()
require.Len(t, prompter.urlCalls, 1)
assert.NotEmpty(t, prompter.urlCalls[0].URL)
}
func TestAuthenticateDeclinedPromptFails(t *testing.T) {
f := newFakeGitHub(t)
m := newManager(t, f)
m.openURL = func(string) error { return errors.New("no browser") }
prompter := &fakePrompter{
urlCapable: true,
onURL: func(_ context.Context, _ Prompt) error {
return ErrPromptDeclined
},
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, err := m.Authenticate(ctx, prompter)
require.Error(t, err, "declining the prompt must abort the flow")
assert.Empty(t, m.AccessToken())
}
func TestAuthenticateUndeliverablePromptFallsBack(t *testing.T) {
f := newFakeGitHub(t)
m := newManager(t, f)
m.openURL = func(string) error { return errors.New("no browser") }
// The client advertised URL elicitation but delivering the prompt fails (a
// transport/protocol error, not a user decision). This must degrade to the
// manual instructions rather than aborting like a decline does.
prompter := &fakePrompter{
urlCapable: true,
onURL: func(_ context.Context, _ Prompt) error {
return ErrPromptUnavailable
},
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
out, err := m.Authenticate(ctx, prompter)
require.NoError(t, err, "an undeliverable prompt must not abort the flow")
require.NotNil(t, out)
require.NotNil(t, out.UserAction, "an undeliverable prompt must fall back to a user action")
assert.NotEmpty(t, out.UserAction.URL)
assert.Contains(t, out.UserAction.Message, securityAdvisory)
// A concurrent retry while awaiting the user returns the same fallback action.
out2, err := m.Authenticate(ctx, nil)
require.NoError(t, err)
require.NotNil(t, out2.UserAction)
assert.Equal(t, out.UserAction.URL, out2.UserAction.URL)
// The background flow stayed alive: opening the URL out of band completes it.
require.NoError(t, browserGet(out.UserAction.URL))
assert.Equal(t, "gho_access", waitForToken(t, m))
}
func TestAuthenticateLastDitchUserAction(t *testing.T) {
f := newFakeGitHub(t)
m := newManager(t, f)
m.openURL = func(string) error { return errors.New("no browser") }
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// No browser and a nil prompter: the only channel left is a user action
// returned to the caller.
out, err := m.Authenticate(ctx, nil)
require.NoError(t, err)
require.NotNil(t, out)
require.NotNil(t, out.UserAction)
assert.NotEmpty(t, out.UserAction.URL)
assert.Contains(t, out.UserAction.Message, "open this URL")
assert.Contains(t, out.UserAction.Message, securityAdvisory,
"missing URL elicitation should trigger the security advisory")
// A concurrent retry while awaiting the user returns the same action, not a
// second flow.
out2, err := m.Authenticate(ctx, nil)
require.NoError(t, err)
require.NotNil(t, out2.UserAction)
assert.Equal(t, out.UserAction.URL, out2.UserAction.URL)
// The user opens the URL out of band; the background flow then completes.
require.NoError(t, browserGet(out.UserAction.URL))
assert.Equal(t, "gho_access", waitForToken(t, m))
}
func TestAuthenticateDeviceFlow(t *testing.T) {
f := newFakeGitHub(t)
f.deviceToken = "gho_device_token"
m := newManager(t, f)
// Inside Docker with a random port, PKCE is impossible, so the device flow
// is selected.
m.inDocker = func() bool { return true }
m.openURL = func(string) error { return errors.New("no browser") }
prompter := &fakePrompter{urlCapable: true} // shows the code, no action needed
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
out, err := m.Authenticate(ctx, prompter)
require.NoError(t, err)
assert.Nil(t, out)
assert.Equal(t, "gho_device_token", m.AccessToken())
assert.Contains(t, f.recordedGrants(), "urn:ietf:params:oauth:grant-type:device_code")
}
func TestAuthenticateDevicePollingPending(t *testing.T) {
f := newFakeGitHub(t)
f.deviceToken = "gho_device_token"
f.devicePending = 1 // one authorization_pending before success
m := newManager(t, f)
m.inDocker = func() bool { return true }
m.openURL = func(string) error { return errors.New("no browser") }
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
defer cancel()
_, err := m.Authenticate(ctx, &fakePrompter{urlCapable: true})
require.NoError(t, err)
assert.Equal(t, "gho_device_token", m.AccessToken())
}
func TestAuthenticateHeadlessPrefersDeviceFlow(t *testing.T) {
f := newFakeGitHub(t)
f.deviceToken = "gho_device_token"
m := newManager(t, f)
// A headless host (no display server) with a random callback port: a PKCE
// redirect to this machine's localhost is unreachable from a browser on
// another machine, so device flow must be chosen even though the client can
// elicit a URL (which would otherwise win over device flow).
m.openURL = func(string) error { return errNoDisplay }
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
out, err := m.Authenticate(ctx, &fakePrompter{urlCapable: true})
require.NoError(t, err)
assert.Nil(t, out)
assert.Equal(t, "gho_device_token", m.AccessToken())
grants := f.recordedGrants()
assert.Contains(t, grants, "urn:ietf:params:oauth:grant-type:device_code")
assert.NotContains(t, grants, "authorization_code",
"headless host must skip the unreachable PKCE authorization-code flow")
}
func TestAuthenticateFixedCallbackPortUnavailableIsFatal(t *testing.T) {
f := newFakeGitHub(t)
m := newManager(t, f)
// Occupy the fixed callback port so the OAuth listener cannot bind it. A held
// port could belong to another user's process that would receive the redirect,
// so the flow must fail loudly rather than quietly downgrade to device flow.
squatter, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
defer squatter.Close()
port := squatter.Addr().(*net.TCPAddr).Port
m.config.CallbackPort = port
// A browser that would have completed PKCE, proving the abort is caused by the
// unavailable port and not by a missing display channel.
m.openURL = browserGet
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
out, err := m.Authenticate(ctx, &fakePrompter{urlCapable: true})
require.Error(t, err)
assert.Nil(t, out)
assert.Contains(t, err.Error(), strconv.Itoa(port))
assert.Empty(t, m.AccessToken())
// The decisive check: no device-code grant was attempted, so the flow did not
// silently fall back when the deliberately chosen port was unavailable.
assert.Empty(t, f.recordedGrants(), "fixed-port bind failure must not fall back to device flow")
}
func TestAuthenticateNoTokenInitially(t *testing.T) {
f := newFakeGitHub(t)
m := newManager(t, f)
assert.False(t, m.HasToken())
assert.Empty(t, m.AccessToken())
}
func TestAuthenticateSingleFlight(t *testing.T) {
f := newFakeGitHub(t)
m := newManager(t, f)
// Hold the owner inside begin() (browser open blocks) so a concurrent caller
// observes the in-progress flow rather than starting its own.
entered := make(chan struct{})
release := make(chan struct{})
var once sync.Once
m.openURL = func(u string) error {
once.Do(func() { close(entered) })
<-release
return browserGet(u)
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
ownerDone := make(chan error, 1)
go func() {
_, err := m.Authenticate(ctx, nil)
ownerDone <- err
}()
<-entered // owner is now mid-flow with status "starting"
out, err := m.Authenticate(ctx, nil)
require.NoError(t, err)
require.NotNil(t, out.UserAction)
assert.Contains(t, out.UserAction.Message, "already in progress")
close(release)
require.NoError(t, <-ownerDone)
assert.Equal(t, "gho_access", waitForToken(t, m))
// Exactly one authorization happened despite the concurrent callers.
assert.Equal(t, []string{"authorization_code"}, f.recordedGrants())
}
+102
View File
@@ -0,0 +1,102 @@
// Package oauth implements the user-facing OAuth 2.1 login flows the stdio
// server uses to obtain a GitHub token without a pre-provisioned Personal
// Access Token.
//
// It supports both GitHub OAuth Apps and GitHub Apps (user-to-server). The
// only practical difference is that GitHub App user tokens expire and carry a
// refresh token; this package always returns a refreshing [golang.org/x/oauth2.TokenSource]
// so callers never have to special-case the app type.
//
// The package depends only on golang.org/x/oauth2 and the standard library. MCP
// concerns (sessions, elicitation) are abstracted behind the [Prompter]
// interface so the flows can be tested without a live client.
package oauth
import (
"crypto/rand"
"encoding/base64"
"fmt"
"strings"
"golang.org/x/oauth2"
)
// Config describes an OAuth client and the GitHub endpoints it talks to.
type Config struct {
ClientID string
ClientSecret string
// Scopes requested during authorization. GitHub Apps ignore these (their
// access is governed by installed permissions); OAuth Apps honor them.
Scopes []string
// Endpoint holds the authorization, token, and device endpoints. Build one
// with [GitHubEndpoint].
Endpoint oauth2.Endpoint
// CallbackPort is the fixed local port for the PKCE callback server. Zero
// requests a random port, which is the secure default for native binaries
// but cannot be reached through Docker port mapping (see the Manager).
CallbackPort int
}
// NewGitHubConfig builds a Config for the given GitHub host. An empty host
// targets github.com; otherwise the host may be a GHES or ghe.com hostname,
// with or without a scheme.
func NewGitHubConfig(clientID, clientSecret string, scopes []string, host string, callbackPort int) Config {
return Config{
ClientID: clientID,
ClientSecret: clientSecret,
Scopes: scopes,
Endpoint: GitHubEndpoint(host),
CallbackPort: callbackPort,
}
}
// GitHubEndpoint returns the OAuth authorization, token, and device endpoints
// for a GitHub host. An empty host targets github.com.
func GitHubEndpoint(host string) oauth2.Endpoint {
base := NormalizeHost(host)
return oauth2.Endpoint{
AuthURL: base + "/login/oauth/authorize",
TokenURL: base + "/login/oauth/access_token",
DeviceAuthURL: base + "/login/device/code",
}
}
// NormalizeHost turns a user-supplied host into a scheme+host base URL with no
// trailing slash. The API subdomain is stripped because OAuth endpoints live on
// the web host, not the API host (api.github.com -> github.com). An empty host
// yields the github.com default, so callers can also use it to recognize the
// default host (NormalizeHost(host) == "https://github.com").
func NormalizeHost(host string) string {
host = strings.TrimSpace(host)
if host == "" {
return "https://github.com"
}
scheme := "https"
switch {
case strings.HasPrefix(host, "https://"):
host = strings.TrimPrefix(host, "https://")
case strings.HasPrefix(host, "http://"):
scheme = "http"
host = strings.TrimPrefix(host, "http://")
}
// Drop any path, query, or fragment; we only need scheme://host.
if i := strings.IndexAny(host, "/?#"); i >= 0 {
host = host[:i]
}
host = strings.TrimPrefix(host, "api.")
return fmt.Sprintf("%s://%s", scheme, host)
}
// randomState returns a cryptographically random URL-safe string used as the
// OAuth state parameter (CSRF protection) and elicitation IDs.
func randomState() (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("generating random state: %w", err)
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
+64
View File
@@ -0,0 +1,64 @@
package oauth
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNormalizeHost(t *testing.T) {
tests := []struct {
name string
host string
want string
}{
{"empty defaults to github.com", "", "https://github.com"},
{"bare host", "github.com", "https://github.com"},
{"https scheme preserved", "https://github.com", "https://github.com"},
{"http scheme preserved", "http://localhost:3000", "http://localhost:3000"},
{"api subdomain stripped", "api.github.com", "https://github.com"},
{"whitespace trimmed", " github.com ", "https://github.com"},
{"path and api stripped", "https://api.github.com/api/v3", "https://github.com"},
{"ghes host", "ghe.example.com", "https://ghe.example.com"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, NormalizeHost(tt.host))
})
}
}
func TestGitHubEndpoint(t *testing.T) {
ep := GitHubEndpoint("")
assert.Equal(t, "https://github.com/login/oauth/authorize", ep.AuthURL)
assert.Equal(t, "https://github.com/login/oauth/access_token", ep.TokenURL)
assert.Equal(t, "https://github.com/login/device/code", ep.DeviceAuthURL)
ghes := GitHubEndpoint("https://ghe.example.com")
assert.Equal(t, "https://ghe.example.com/login/oauth/authorize", ghes.AuthURL)
}
func TestNewGitHubConfig(t *testing.T) {
cfg := NewGitHubConfig("client", "secret", []string{"repo", "read:org"}, "", 8085)
assert.Equal(t, "client", cfg.ClientID)
assert.Equal(t, "secret", cfg.ClientSecret)
assert.Equal(t, []string{"repo", "read:org"}, cfg.Scopes)
assert.Equal(t, 8085, cfg.CallbackPort)
assert.Equal(t, GitHubEndpoint(""), cfg.Endpoint)
}
func TestRandomState(t *testing.T) {
s1, err := randomState()
require.NoError(t, err)
s2, err := randomState()
require.NoError(t, err)
assert.NotEqual(t, s1, s2, "state must be unique per call")
assert.NotContains(t, s1, "=", "state must be URL-safe without padding")
assert.NotContains(t, s1, "+")
assert.NotContains(t, s1, "/")
assert.GreaterOrEqual(t, len(s1), 22, "16 random bytes encode to 22 base64url chars")
assert.False(t, strings.ContainsAny(s1, " \t\n"))
}
+65
View File
@@ -0,0 +1,65 @@
package oauth
import (
"context"
"errors"
)
// ErrPromptDeclined is returned by a Prompter when the user actively cancels or
// declines the authorization prompt. It is a deliberate "no", so the flow stops
// rather than falling back to another channel.
var ErrPromptDeclined = errors.New("authorization declined by user")
// ErrPromptUnavailable is returned by a Prompter when the prompt could not be
// delivered at all — for example the client advertised an elicitation capability
// but the request failed at the transport or protocol level. Unlike
// ErrPromptDeclined it reflects no user decision, so the flow falls back to a
// channel that needs no client capability instead of giving up.
var ErrPromptUnavailable = errors.New("authorization prompt could not be delivered")
// Prompt is the content shown to the user when asking them to authorize.
type Prompt struct {
// Message is a human-readable instruction.
Message string
// URL is the authorization URL (PKCE) or device verification URI.
URL string
// UserCode is the device-flow code the user must enter, if any.
UserCode string
}
// Prompter presents authorization prompts to the user out of band from the LLM
// context — for example via MCP elicitation. Keeping prompts out of the model's
// context prevents the authorization URL (and any session-bound state) from
// leaking into tool arguments or transcripts.
//
// A nil Prompter is valid and reports no capabilities, which drives the flow to
// its last-resort channel. Implementations wrap a transport-specific client
// (e.g. an MCP session); see the ghmcp adapter.
type Prompter interface {
// CanPromptURL reports whether the client can display a URL securely via
// URL-mode elicitation.
CanPromptURL() bool
// PromptURL securely presents an authorization URL to the user and blocks
// until the user acknowledges, declines, or ctx is done. Returning nil means
// the prompt was shown (not that authorization completed); the caller waits
// for the OAuth flow itself to finish. It returns ErrPromptDeclined if the
// user declines or cancels, or ErrPromptUnavailable if the prompt could not
// be delivered.
PromptURL(ctx context.Context, p Prompt) error
// CanPromptForm reports whether the client supports form elicitation, used
// to display a device code when URL elicitation is unavailable.
CanPromptForm() bool
// PromptForm presents a textual acknowledgement prompt and blocks until the
// user responds. It returns ErrPromptDeclined if the user declines, or
// ErrPromptUnavailable if the prompt could not be delivered.
PromptForm(ctx context.Context, p Prompt) error
}
// canPromptURL reports URL support, tolerating a nil Prompter.
func canPromptURL(p Prompter) bool { return p != nil && p.CanPromptURL() }
// canPromptForm reports form support, tolerating a nil Prompter.
func canPromptForm(p Prompter) bool { return p != nil && p.CanPromptForm() }
+60
View File
@@ -0,0 +1,60 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Authorization Failed</title>
<style>
html, body { height: 100%; margin: 0; }
body {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif;
background-color: #0d1117;
color: #e6edf3;
}
.card {
width: 500px;
background-color: #161b22;
border: 1px solid #30363d;
border-radius: 6px;
padding: 32px;
text-align: center;
}
.octicon { margin-bottom: 16px; }
h1 {
font-size: 20px;
font-weight: 600;
margin: 0 0 12px 0;
color: #f85149;
}
p { font-size: 16px; color: #8b949e; margin: 16px 0 0 0; }
.flash-error {
margin-top: 16px;
padding: 12px 16px;
background-color: rgba(248, 81, 73, 0.1);
border: 1px solid rgba(248, 81, 73, 0.4);
border-radius: 6px;
}
code {
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
font-size: 14px;
color: #f85149;
}
</style>
</head>
<body>
<div class="card">
<svg class="octicon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="80" height="80" fill="currentColor">
<path d="M8 0c4.42 0 8 3.58 8 8a8.013 8.013 0 0 1-5.45 7.59c-.4.08-.55-.17-.55-.38 0-.27.01-1.13.01-2.2 0-.75-.25-1.23-.54-1.48 1.78-.2 3.65-.88 3.65-3.95 0-.88-.31-1.59-.82-2.15.08-.2.36-1.02-.08-2.12 0 0-.67-.22-2.2.82-.64-.18-1.32-.27-2-.27-.68 0-1.36.09-2 .27-1.53-1.03-2.2-.82-2.2-.82-.44 1.1-.16 1.92-.08 2.12-.51.56-.82 1.28-.82 2.15 0 3.06 1.86 3.75 3.64 3.95-.23.2-.44.55-.51 1.07-.46.21-1.61.55-2.33-.66-.15-.24-.6-.83-1.23-.82-.67.01-.27.38.01.53.34.19.73.9.82 1.13.16.45.68 1.31 2.69.94 0 .67.01 1.3.01 1.49 0 .21-.15.45-.55.38A7.995 7.995 0 0 1 0 8c0-4.42 3.58-8 8-8Z"></path>
</svg>
<h1>Authorization Failed</h1>
<div class="flash-error">
<code>{{.ErrorMessage}}</code>
</div>
<p>You can close this window.</p>
</div>
</body>
</html>
+56
View File
@@ -0,0 +1,56 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Authorization Successful</title>
<style>
html, body { height: 100%; margin: 0; }
body {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif;
background-color: #0d1117;
color: #e6edf3;
}
.card {
width: 500px;
background-color: #161b22;
border: 1px solid #30363d;
border-radius: 6px;
padding: 32px;
text-align: center;
}
.octicon { margin-bottom: 16px; }
h1 {
font-size: 20px;
font-weight: 600;
margin: 0 0 12px 0;
color: #3fb950;
}
p { font-size: 16px; color: #8b949e; margin: 0; }
.flash {
margin-top: 16px;
padding: 12px 16px;
background-color: rgba(56, 139, 253, 0.15);
border: 1px solid rgba(56, 139, 253, 0.4);
border-radius: 6px;
}
.flash p { font-size: 14px; }
</style>
</head>
<body>
<div class="card">
<svg class="octicon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="80" height="80" fill="currentColor">
<path d="M8 0c4.42 0 8 3.58 8 8a8.013 8.013 0 0 1-5.45 7.59c-.4.08-.55-.17-.55-.38 0-.27.01-1.13.01-2.2 0-.75-.25-1.23-.54-1.48 1.78-.2 3.65-.88 3.65-3.95 0-.88-.31-1.59-.82-2.15.08-.2.36-1.02-.08-2.12 0 0-.67-.22-2.2.82-.64-.18-1.32-.27-2-.27-.68 0-1.36.09-2 .27-1.53-1.03-2.2-.82-2.2-.82-.44 1.1-.16 1.92-.08 2.12-.51.56-.82 1.28-.82 2.15 0 3.06 1.86 3.75 3.64 3.95-.23.2-.44.55-.51 1.07-.46.21-1.61.55-2.33-.66-.15-.24-.6-.83-1.23-.82-.67.01-.27.38.01.53.34.19.73.9.82 1.13.16.45.68 1.31 2.69.94 0 .67.01 1.3.01 1.49 0 .21-.15.45-.55.38A7.995 7.995 0 0 1 0 8c0-4.42 3.58-8 8-8Z"></path>
</svg>
<h1>Authorization Successful</h1>
<p>You have successfully authorized the GitHub MCP Server.</p>
<div class="flash">
<p>You can close this window and retry your request.</p>
</div>
</div>
</body>
</html>

Some files were not shown because too many files have changed in this diff Show More