Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -403,7 +403,13 @@ For a complete overview of all installation options, see our **[Installation Gui
### Build from source

If you don't have Docker, you can use `go build` to build the binary in the
`cmd/github-mcp-server` directory, and use the `github-mcp-server stdio` command with the `GITHUB_PERSONAL_ACCESS_TOKEN` environment variable set to your token. To specify the output location of the build, use the `-o` flag. You should configure your server to use the built executable as its `command`. For example:
`cmd/github-mcp-server` directory, and use the `github-mcp-server stdio` command with the `GITHUB_PERSONAL_ACCESS_TOKEN` environment variable set to your token. To specify the output location of the build, use the `-o` flag. You should configure your server to use the built executable as its `command`.

STDIO API requests identify the server as `github-mcp-server/<version>` and retain the upstream MCP client's name/version in parentheses when available. Release builds keep their release version. Source and default Docker builds use `vcs-<full-commit-sha>` (with `-dirty` when embedded VCS metadata records modified source), rather than the placeholder `version` or `dev`. This is a VCS build identifier, not a release number.

Build the complete package with `go build -o github-mcp-server ./cmd/github-mcp-server` from a Git checkout to embed its VCS revision. For builds without VCS metadata, supply the actual release with `-ldflags '-X main.version=<release>'` or the full source revision with `-ldflags '-X main.commit=<sha>'`. STDIO startup reports an error if neither a real release nor a valid revision is available.

For example:

```JSON
{
Expand Down
9 changes: 8 additions & 1 deletion cmd/github-mcp-server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"os"
"runtime/debug"
"strings"
"time"

Expand Down Expand Up @@ -39,6 +40,12 @@ var (
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 {
info, _ := debug.ReadBuildInfo()
serverVersion, err := resolveServerVersion(version, commit, info)
if err != nil {
return err
}

token := viper.GetString("personal_access_token")
appID := viper.GetString("app-id")
appInstallationID := viper.GetString("app-installation-id")
Expand Down Expand Up @@ -110,7 +117,7 @@ var (

ttl := viper.GetDuration("repo-access-cache-ttl")
stdioServerConfig := ghmcp.StdioServerConfig{
Version: version,
Version: serverVersion,
Host: viper.GetString("host"),
Token: token,
EnabledToolsets: enabledToolsets,
Expand Down
64 changes: 64 additions & 0 deletions cmd/github-mcp-server/version.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package main

import (
"encoding/hex"
"fmt"
"runtime/debug"
"strings"
"unicode"
)

func resolveServerVersion(release, revision string, info *debug.BuildInfo) (string, error) {
isPlaceholder := func(value string) bool {
switch value {
case "", "version", "dev", "unknown", "(devel)":
return true
default:
return false
}
}
validateRelease := func(value string) (string, error) {
for _, r := range value {
if r > unicode.MaxASCII || r <= ' ' || r == 127 || strings.ContainsRune("()<>@,;:\\\"/[]?={}", r) {
return "", fmt.Errorf("server version must be an HTTP product token: %q", value)
}
}
return value, nil
}
if !isPlaceholder(release) {
return validateRelease(release)
}

var vcsRevision string
var dirty bool
if info != nil {
for _, setting := range info.Settings {
switch setting.Key {
case "vcs.revision":
vcsRevision = setting.Value
case "vcs.modified":
dirty = setting.Value == "true"
}
}
}
if revision == "" || revision == "commit" {
revision = vcsRevision
if revision == "" && info != nil && !isPlaceholder(info.Main.Version) {
return validateRelease(info.Main.Version)
}
}
if revision == "" {
return "", fmt.Errorf("server build has no release or revision: build the package with VCS metadata or set main.version/main.commit using -ldflags")
}
if len(revision) != 40 && len(revision) != 64 {
return "", fmt.Errorf("server build revision must be a full SHA-1 or SHA-256: %q", revision)
}
if _, err := hex.DecodeString(revision); err != nil {
return "", fmt.Errorf("invalid server build revision: %w", err)
}
resolved := "vcs-" + strings.ToLower(revision)
if dirty {
resolved += "-dirty"
Comment on lines +60 to +61
}
return resolved, nil
}
75 changes: 75 additions & 0 deletions cmd/github-mcp-server/version_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package main

import (
"runtime/debug"
"strings"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestResolveServerVersion(t *testing.T) {
t.Parallel()
sha := strings.Repeat("a1", 20)
otherSHA := strings.Repeat("b2", 20)
source := &debug.BuildInfo{
Main: debug.Module{Version: "(devel)"},
Settings: []debug.BuildSetting{
{Key: "vcs.revision", Value: sha}, {Key: "vcs.modified", Value: "false"},
},
}
dirty := &debug.BuildInfo{
Main: debug.Module{Version: "(devel)"},
Settings: []debug.BuildSetting{
{Key: "vcs.revision", Value: sha}, {Key: "vcs.modified", Value: "true"},
},
}
tests := []struct {
name string
release string
revision string
info *debug.BuildInfo
want string
wantErr string
}{
{name: "release unchanged", release: "v1.2.3", revision: sha, info: dirty, want: "v1.2.3"},
{name: "release suffix unchanged", release: "v1.2.3-rc.1+build.4", want: "v1.2.3-rc.1+build.4"},
{name: "source build", release: "version", revision: "commit", info: source, want: "vcs-" + sha},
{
name: "source revision before inferred module version", release: "version", revision: "commit",
info: &debug.BuildInfo{
Main: debug.Module{Version: "v1.2.4-0.20260916095829-a1a1a1a1a1a1+dirty"},
Settings: dirty.Settings,
},
want: "vcs-" + sha + "-dirty",
},
{name: "dirty source", release: "version", revision: "commit", info: dirty, want: "vcs-" + sha + "-dirty"},
{name: "Docker revision", release: "dev", revision: sha, want: "vcs-" + sha},
{name: "linked revision precedence", release: "dev", revision: otherSHA, info: source, want: "vcs-" + otherSHA},
{name: "SHA256", release: "dev", revision: strings.Repeat("ab", 32), want: "vcs-" + strings.Repeat("ab", 32)},
{name: "canonical hex", release: "dev", revision: strings.ToUpper(sha), want: "vcs-" + sha},
{name: "installed module", info: &debug.BuildInfo{Main: debug.Module{Version: "v1.2.3"}}, want: "v1.2.3"},
{name: "absent build info", release: "version", revision: "commit", wantErr: "no release or revision"},
{name: "missing VCS metadata", release: "dev", info: &debug.BuildInfo{}, wantErr: "no release or revision"},
{name: "unknown placeholder", release: "unknown", wantErr: "no release or revision"},
{name: "short revision", release: "dev", revision: "abcdef", wantErr: "full SHA-1 or SHA-256"},
{name: "malformed linked revision", release: "dev", revision: strings.Repeat("x", 40), info: source, wantErr: "invalid server build revision"},
{name: "release whitespace", release: "v1.2.3 extra", wantErr: "HTTP product token"},
{name: "release newline", release: "v1.2.3\n", wantErr: "HTTP product token"},
{name: "release slash", release: "release/v1.2.3", wantErr: "HTTP product token"},
{name: "release non ASCII", release: "v1.2.3-\u00e9", wantErr: "HTTP product token"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := resolveServerVersion(tt.release, tt.revision, tt.info)
if tt.wantErr != "" {
require.ErrorContains(t, err, tt.wantErr)
assert.Empty(t, got)
} else {
require.NoError(t, err)
assert.Equal(t, tt.want, got)
}
})
}
}
2 changes: 1 addition & 1 deletion docs/installation-guides/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ The GitHub MCP Server can be installed using several methods. **Docker is the mo
- **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`
- **Build command**: `go build -o github-mcp-server ./cmd/github-mcp-server` (build the complete package from a Git checkout to retain its source revision).
- **Best for**: Developers who want the latest features or need custom modifications

### Important Notes on the GitHub MCP Server
Expand Down
74 changes: 31 additions & 43 deletions internal/ghmcp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,10 @@ import (

// 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
rest *gogithub.Client
gql *githubv4.Client
raw *raw.Client
repoAccess *lockdown.RepoAccessCache
}

// createGitHubClients creates all the GitHub API clients needed by the server.
Expand Down Expand Up @@ -90,7 +88,7 @@ func createGitHubClients(cfg github.MCPServerConfig, apiHost utils.APIHostResolv
// client per request (see pkg/github RequestDeps) and does not use this path.
restUATransport := &transport.UserAgentTransport{
Transport: &transport.ETagTransport{Transport: http.DefaultTransport},
Agent: fmt.Sprintf("github-mcp-server/%s", cfg.Version),
Agent: stdioUserAgent(cfg, nil),
}
restClient, err := newRESTClient(cfg, restUATransport, restURL.String(), uploadURL.String(), allowedHosts)
if err != nil {
Expand All @@ -102,7 +100,10 @@ func createGitHubClients(cfg github.MCPServerConfig, apiHost utils.APIHostResolv
gqlHTTPClient := &http.Client{
Transport: &transport.BearerAuthTransport{
Transport: &transport.GraphQLFeaturesTransport{
Transport: http.DefaultTransport,
Transport: &transport.UserAgentTransport{
Transport: http.DefaultTransport,
Agent: stdioUserAgent(cfg, nil),
},
},
Token: cfg.Token,
TokenProvider: cfg.TokenProvider,
Expand All @@ -117,7 +118,7 @@ func createGitHubClients(cfg github.MCPServerConfig, apiHost utils.APIHostResolv
// be large and are streamed rather than retained in memory.
rawUATransport := &transport.UserAgentTransport{
Transport: http.DefaultTransport,
Agent: fmt.Sprintf("github-mcp-server/%s", cfg.Version),
Agent: stdioUserAgent(cfg, nil),
}
rawRESTClient, err := newRESTClient(cfg, rawUATransport, restURL.String(), uploadURL.String(), allowedHosts)
if err != nil {
Expand All @@ -141,12 +142,10 @@ func createGitHubClients(cfg github.MCPServerConfig, apiHost utils.APIHostResolv
}

return &githubClients{
rest: restClient,
restUATransp: restUATransport,
gql: gqlClient,
gqlHTTP: gqlHTTPClient,
raw: rawClient,
repoAccess: repoAccessCache,
rest: restClient,
gql: gqlClient,
raw: rawClient,
repoAccess: repoAccessCache,
}, nil
}

Expand Down Expand Up @@ -232,7 +231,7 @@ func NewStdioMCPServer(ctx context.Context, cfg github.MCPServerConfig) (*mcp.Se
return nil, fmt.Errorf("failed to create GitHub MCP server: %w", err)
}

ghServer.AddReceivingMiddleware(addUserAgentsMiddleware(cfg, clients.restUATransp, clients.gqlHTTP))
ghServer.AddReceivingMiddleware(addUserAgentsMiddleware(cfg))

return ghServer, nil
}
Expand Down Expand Up @@ -441,36 +440,25 @@ func createFeatureChecker(enabledFeatures []string, insidersMode bool) inventory
}
}

func addUserAgentsMiddleware(cfg github.MCPServerConfig, restUATransp *transport.UserAgentTransport, gqlHTTPClient *http.Client) func(next mcp.MethodHandler) mcp.MethodHandler {
func stdioUserAgent(cfg github.MCPServerConfig, client *mcp.Implementation) string {
agent := fmt.Sprintf("github-mcp-server/%s", cfg.Version)
if client != nil {
agent += fmt.Sprintf(" (%s/%s)", client.Name, client.Version)
}
if cfg.InsidersMode {
agent += " (insiders)"
}
return agent
}

func addUserAgentsMiddleware(cfg github.MCPServerConfig) 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)
var client *mcp.Implementation
if info, ok := request.(interface{ ClientInfo() *mcp.Implementation }); ok {
client = info.ClientInfo()
}

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,
}

ctx = transport.WithUserAgent(ctx, stdioUserAgent(cfg, client))
return next(ctx, method, request)
}
}
Expand Down
Loading
Loading