Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
b463b64
centralise lockdown checks and default fail closed on empty author
kerobbi Jul 14, 2026
5a0beac
test lockdown lookup-failure returns tool-result error
kerobbi Jul 14, 2026
0ad8cc6
enforce lockdown on pr diff/files/check_runs and fix reviews fail-open
kerobbi Jul 14, 2026
8f6aa8e
fix flaky test
kerobbi Jul 14, 2026
334aac0
remove get_check_runs gate
kerobbi Jul 16, 2026
0e9bf0c
build(deps): bump actions/cache from 5 to 6
dependabot[bot] Jul 15, 2026
4307ff8
build(deps): bump node from `a2dc166` to `e88a35b`
dependabot[bot] Jul 15, 2026
225ba20
perf(octicons): embed precomputed data URIs
SamMorrowDrums Jul 16, 2026
870f3c7
fix(labels): add DestructiveHint to label_write tool (#2763)
syf2211 Jul 16, 2026
1338dbe
build(deps): bump go-sdk to 1.7.0-pre.3 (#2907)
SamMorrowDrums Jul 18, 2026
9184f77
build(deps): bump distroless/base-debian12 from `9c05cfd` to `348dac1`
dependabot[bot] Jul 20, 2026
6a44cf2
Paginate project item lookup (#2914)
zwick Jul 21, 2026
9d13004
Add opt-in intent-aware Copilot issue assignment tool (#2909)
boazreicher Jul 21, 2026
4c68b1b
Add node IDs to project resolver results
zwick Jul 21, 2026
de310d4
Address CCR feedback: assert resolved field NodeID for all variants; …
zwick Jul 21, 2026
4ed4f81
Extract aliased project mutation primitive (#2923)
zwick Jul 22, 2026
d3cd405
build: use patched Go toolchain and UI dependency (#2927)
loganrosen Jul 22, 2026
e05a384
feat(auth): add GitHub App server-to-server authentication for stdio
SamMorrowDrums Jun 29, 2026
ea4e396
refactor(auth): isolate GitHub App auth to stdio startup
SamMorrowDrums Jul 22, 2026
c5f4caa
build(deps): bump actions/setup-go from 6 to 7
dependabot[bot] Jul 20, 2026
5d13598
build(deps): bump actions/setup-node from 6 to 7
dependabot[bot] Jul 20, 2026
4f26c17
build(deps): bump golang.org/x/oauth2 from 0.35.0 to 0.36.0
dependabot[bot] Jul 16, 2026
b8bfb49
chore: regenerate license files
github-actions[bot] Jul 16, 2026
a217a7f
Add MCP App form deferral opt-out
connor4312 Jul 21, 2026
d1dd472
build(deps): bump the npm_and_yarn group across 1 directory with 2 up…
dependabot[bot] Jul 22, 2026
eb088df
fix: bump Node.js from 20 to 22 in build-ui action
Copilot Jul 23, 2026
1348c47
Bump go-github to pick up SearchType support (#2972)
kelsey-myers Jul 29, 2026
456fae9
Make fields parameter available by default (#2952)
tommaso-moro Jul 29, 2026
d080b23
Add batched update_project_items writes via GraphQL (#2903)
veralizeth Jul 29, 2026
96a3d78
build(deps): bump modelcontextprotocol/go-sdk to v1.7.0
SamMorrowDrums Jul 28, 2026
ea8099d
fix: don't advertise unsupported list-changed capabilities
SamMorrowDrums Jul 28, 2026
ca8ab52
test: assert advertised capabilities omit list-changed
SamMorrowDrums Jul 30, 2026
3778a41
Clarify that create_or_update_file content is plain text (#2983)
ericsciple Jul 31, 2026
e6e3a4e
Return closing pull requests from issue_read (#3006)
zwick Aug 6, 2026
f3cb662
Make search_issues semantic by default (#2964)
kelsey-myers Aug 6, 2026
e7f7bb8
Support removing issue types (#2999)
zwick Aug 7, 2026
1b3f89a
Add non-default find_duplicate tool gated by duplicate_detection flag…
michaeljacholke Aug 7, 2026
eb4c099
Support singular Project Issue Field updates (#2941)
zwick Aug 7, 2026
cdfa34e
Order list_label results by issue count (descending) (#2974)
iulia-b Aug 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/actions/build-ui/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ runs:
if: steps.cache-ui.outputs.cache-hit != 'true'
uses: actions/setup-node@v6
with:
node-version: "20"
node-version: "22"
cache: npm
cache-dependency-path: ui/package-lock.json

Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/code-scanning.yml
Original file line number Diff line number Diff line change
Expand Up @@ -72,15 +72,15 @@ jobs:
with:
language: ${{ matrix.language }}
- name: Setup Go
uses: actions/setup-go@v6
uses: actions/setup-go@v7
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
uses: actions/setup-node@v7
with:
node-version: "20"
cache: "npm"
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/docker-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ jobs:
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
uses: actions/cache@v6
with:
path: go-build-cache
key: ${{ runner.os }}-go-build-cache-${{ hashFiles('**/go.sum') }}
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/docs-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ jobs:
uses: ./.github/actions/build-ui

- name: Set up Go
uses: actions/setup-go@v6
uses: actions/setup-go@v7
with:
go-version-file: 'go.mod'

Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/go.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ jobs:
uses: ./.github/actions/build-ui

- name: Set up Go
uses: actions/setup-go@v6
uses: actions/setup-go@v7
with:
go-version-file: "go.mod"

Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/goreleaser.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ jobs:
uses: ./.github/actions/build-ui

- name: Set up Go
uses: actions/setup-go@v6
uses: actions/setup-go@v7
with:
go-version-file: "go.mod"

Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/license-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ jobs:
uses: ./.github/actions/build-ui

- name: Set up Go
uses: actions/setup-go@v6
uses: actions/setup-go@v7
with:
go-version-file: "go.mod"

Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ jobs:
- uses: actions/checkout@v7
- name: Build UI
uses: ./.github/actions/build-ui
- uses: actions/setup-go@v6
- uses: actions/setup-go@v7
with:
go-version: '1.25'
- name: golangci-lint
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/mcp-diff.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ jobs:
fetch-depth: 0

- name: Set up Go
uses: actions/setup-go@v6
uses: actions/setup-go@v7
with:
go-version-file: go.mod

Expand Down Expand Up @@ -90,7 +90,7 @@ jobs:
fetch-depth: 0

- name: Set up Go
uses: actions/setup-go@v6
uses: actions/setup-go@v7
with:
go-version-file: go.mod

Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/registry-releaser.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ jobs:
uses: actions/checkout@v7

- name: Setup Go
uses: actions/setup-go@v6
uses: actions/setup-go@v7
with:
go-version: "stable"

Expand Down
4 changes: 2 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
FROM node:26-alpine@sha256:a2dc166a387cc6ca1e62d0c8e265e49ca985d6e60abc9fe6e6c3d6ce8e63f606 AS ui-build
FROM node:26-alpine@sha256:e88a35be04478413b7c71c455cd9865de9b9360e1f43456be5951032d7ac1a66 AS ui-build
WORKDIR /app
COPY ui/package*.json ./ui/
RUN cd ui && npm ci
Expand Down Expand Up @@ -35,7 +35,7 @@ RUN --mount=type=cache,target=/go/pkg/mod \
-o /bin/github-mcp-server ./cmd/github-mcp-server

# Make a stage to run the app
FROM gcr.io/distroless/base-debian12@sha256:9c05cfd65f41c93a909ea67eb05b920a3b838780ea55df5421d48295d98ff957
FROM gcr.io/distroless/base-debian12@sha256:348dac1808083ccc3366399d6db835875b4eaf7c9b694783f5a3f353c4b58a28

# Add required MCP server annotation
LABEL io.modelcontextprotocol.server.name="io.github.github/github-mcp-server"
Expand Down
41 changes: 35 additions & 6 deletions README.md

Large diffs are not rendered by default.

48 changes: 35 additions & 13 deletions cmd/github-mcp-server/generate_docs.go
Original file line number Diff line number Diff line change
Expand Up @@ -273,19 +273,7 @@ func writeToolDoc(buf *strings.Builder, tool inventory.ServerTool) {
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
}
typeStr := schemaTypeString(prop)

// Indent any continuation lines in the description to maintain markdown formatting
description := indentMultilineDescription(prop.Description, " ")
Expand All @@ -300,6 +288,40 @@ func writeToolDoc(buf *strings.Builder, tool inventory.ServerTool) {
}
}

func schemaTypeString(schema *jsonschema.Schema) string {
switch {
case schema.Type == "array":
if schema.Items != nil {
return schema.Items.Type + "[]"
}
return "array"
case schema.Type != "":
return schema.Type
case len(schema.Types) > 0:
return strings.Join(schema.Types, " | ")
}

var union []*jsonschema.Schema
switch {
case len(schema.AnyOf) > 0:
union = schema.AnyOf
case len(schema.OneOf) > 0:
union = schema.OneOf
default:
// A schema without type constraints accepts any value.
return "any"
}

types := make([]string, 0, len(union))
for _, member := range union {
memberType := schemaTypeString(member)
if !slices.Contains(types, memberType) {
types = append(types, memberType)
}
}
return strings.Join(types, " | ")
}

// scopesEqual checks if two scope slices contain the same elements (order-independent)
func scopesEqual(a, b []string) bool {
if len(a) != len(b) {
Expand Down
81 changes: 77 additions & 4 deletions cmd/github-mcp-server/main.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package main

import (
"context"
"errors"
"fmt"
"os"
Expand All @@ -9,10 +10,12 @@ import (

"github.com/github/github-mcp-server/internal/buildinfo"
"github.com/github/github-mcp-server/internal/ghmcp"
"github.com/github/github-mcp-server/internal/githubapp"
"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/github/github-mcp-server/pkg/utils"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
"github.com/spf13/viper"
Expand All @@ -37,6 +40,12 @@ var (
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")
appID := viper.GetString("app-id")
appInstallationID := viper.GetString("app-installation-id")
appPrivateKeyPath := viper.GetString("app-private-key-path")
appPrivateKeyInline := viper.GetString("app-private-key")
appAuthRequested := appID != "" || appInstallationID != "" || appPrivateKeyPath != "" || appPrivateKeyInline != ""

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
Expand All @@ -46,12 +55,18 @@ var (
// 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" {
if oauthClientID == "" && !appAuthRequested && 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 token == "" && !appAuthRequested && oauthClientID == "" {
return errors.New("authentication required: set GITHUB_PERSONAL_ACCESS_TOKEN, configure GitHub App auth, or pass --oauth-client-id to log in via OAuth")
}
if appAuthRequested && token != "" {
return errors.New("GitHub App authentication and GITHUB_PERSONAL_ACCESS_TOKEN are mutually exclusive: set only one")
}
if appAuthRequested && oauthClientID != "" {
return errors.New("GitHub App authentication and OAuth login (--oauth-client-id) are mutually exclusive: set only one")
}

// If you're wondering why we're not using viper.GetStringSlice("toolsets"),
Expand Down Expand Up @@ -116,7 +131,7 @@ var (
// 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 == "" {
if token == "" && !appAuthRequested {
scopes := ghoauth.SupportedScopes
if viper.IsSet("oauth-scopes") {
if err := viper.UnmarshalKey("oauth-scopes", &scopes); err != nil {
Expand All @@ -134,6 +149,14 @@ var (
stdioServerConfig.OAuthScopes = scopes
}

if appAuthRequested {
tokenProvider, err := newGitHubAppTokenProvider(appID, appInstallationID, appPrivateKeyPath, appPrivateKeyInline, viper.GetString("host"))
if err != nil {
return err
}
stdioServerConfig.TokenProvider = tokenProvider
}

return ghmcp.RunStdioServer(stdioServerConfig)
},
}
Expand Down Expand Up @@ -230,6 +253,11 @@ func init() {
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")

// The private key has no flag because passing it in argv would expose it.
stdioCmd.Flags().String("app-id", "", "GitHub App ID or client ID, enabling non-interactive server-to-server authentication")
stdioCmd.Flags().String("app-installation-id", "", "GitHub App installation ID to mint installation access tokens for")
stdioCmd.Flags().String("app-private-key-path", "", "Path to the GitHub App private key (PEM). Preferred over GITHUB_APP_PRIVATE_KEY: keeps the key off the command line and out of the environment")

// 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.")
Expand All @@ -256,6 +284,9 @@ func init() {
_ = 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("app-id", stdioCmd.Flags().Lookup("app-id"))
_ = viper.BindPFlag("app-installation-id", stdioCmd.Flags().Lookup("app-installation-id"))
_ = viper.BindPFlag("app-private-key-path", stdioCmd.Flags().Lookup("app-private-key-path"))
_ = 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"))
Expand All @@ -281,6 +312,48 @@ func main() {
}
}

func newGitHubAppTokenProvider(appID, installationID, keyPath, keyInline, host string) (func() string, error) {
keyBytes, err := loadAppPrivateKey(keyPath, keyInline)
if err != nil {
return nil, err
}

apiHost, err := utils.NewAPIHost(host)
if err != nil {
return nil, fmt.Errorf("failed to parse host for GitHub App authentication: %w", err)
}
restURL, err := apiHost.BaseRESTURL(context.Background())
if err != nil {
return nil, fmt.Errorf("failed to resolve REST URL for GitHub App authentication: %w", err)
}

provider, err := githubapp.NewProvider(githubapp.Config{
AppID: appID,
InstallationID: installationID,
PrivateKeyPEM: keyBytes,
BaseRESTURL: restURL.String(),
}, nil)
if err != nil {
return nil, fmt.Errorf("failed to configure GitHub App authentication: %w", err)
}
return provider.AccessToken, nil
}

func loadAppPrivateKey(path, inline string) ([]byte, error) {
switch {
case path != "":
data, err := os.ReadFile(path) //#nosec G304 -- operator-supplied path to their own key
if err != nil {
return nil, fmt.Errorf("reading GitHub App private key file: %w", err)
}
return data, nil
case inline != "":
return []byte(strings.ReplaceAll(inline, `\n`, "\n")), nil
default:
return nil, errors.New("GitHub App authentication requires a private key: set GITHUB_APP_PRIVATE_KEY_PATH (preferred) or GITHUB_APP_PRIVATE_KEY")
}
}

func wordSepNormalizeFunc(_ *pflag.FlagSet, name string) pflag.NormalizedName {
from := []string{"_"}
to := "-"
Expand Down
Loading