chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
.github
|
||||
.vscode
|
||||
script
|
||||
third-party
|
||||
.dockerignore
|
||||
.gitignore
|
||||
**/*.yml
|
||||
**/*.yaml
|
||||
**/*.md
|
||||
**/*_test.go
|
||||
LICENSE
|
||||
@@ -0,0 +1 @@
|
||||
* @github/github-mcp-server
|
||||
@@ -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.
|
||||
@@ -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 you’d 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 3–5 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:
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -0,0 +1,51 @@
|
||||
<!--
|
||||
Copilot: Fill all sections. Prefer short, concrete answers.
|
||||
If a checkbox is selected, add a brief explanation.
|
||||
-->
|
||||
|
||||
## Summary
|
||||
<!-- In 1–2 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 1–2 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)
|
||||
@@ -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 }}
|
||||
@@ -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 }}
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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$
|
||||
@@ -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}}"
|
||||
Vendored
+38
@@ -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",
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -0,0 +1,7 @@
|
||||
# WeHub 来源说明
|
||||
|
||||
- 原始项目:`github/github-mcp-server`
|
||||
- 原始仓库:https://github.com/github/github-mcp-server
|
||||
- 导入方式:上游默认分支的最新快照
|
||||
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
|
||||
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
|
||||
+31
@@ -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
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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, " ")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"], ¶ms); 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")
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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 -->
|
||||
@@ -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 organization’s 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)
|
||||
@@ -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`.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
[](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
|
||||
|
||||
[](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
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,145 @@
|
||||
# Remote GitHub MCP Server 🚀
|
||||
|
||||
[](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) [](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"
|
||||
}
|
||||
```
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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 -->
|
||||
@@ -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
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
package ghmcp
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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, "<script>")
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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() }
|
||||
@@ -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>
|
||||
@@ -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
Reference in New Issue
Block a user