🚀 Socket Launch Week Day 5:Introducing Repository Access Permissions and Custom Roles.Learn more
Sign In

mirror-github.paniser.workers.dev/github/github-mcp-server

Package Overview
Dependencies
Versions
77
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

mirror-github.paniser.workers.dev/github/github-mcp-server - go Package Compare versions

Comparing version
v1.4.1-0.20260619120947-a37837027f6a
to
v1.5.0
+266
docs/oauth-login.md
# 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.
// 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
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")
}
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)
package oauth
import (
"net"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// serveCallback drives the callback handler with the given query string and
// returns the recorded response and the single reported result.
func serveCallback(t *testing.T, expectedState, query string) (*httptest.ResponseRecorder, callbackResult) {
t.Helper()
cs := &callbackServer{results: make(chan callbackResult, 1)}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/callback?"+query, nil)
cs.handler(expectedState).ServeHTTP(rec, req)
select {
case res := <-cs.results:
return rec, res
default:
t.Fatal("handler did not report a result")
return nil, callbackResult{}
}
}
func TestCallbackHandlerSuccess(t *testing.T) {
rec, res := serveCallback(t, "state123", "code=the-code&state=state123")
require.NoError(t, res.err)
assert.Equal(t, "the-code", res.code)
assert.Equal(t, http.StatusOK, rec.Code)
assert.Contains(t, rec.Body.String(), "Authorization Successful")
}
func TestCallbackHandlerStateMismatch(t *testing.T) {
rec, res := serveCallback(t, "expected", "code=the-code&state=attacker")
require.Error(t, res.err)
assert.Empty(t, res.code)
assert.Contains(t, res.err.Error(), "state mismatch")
assert.Contains(t, rec.Body.String(), "state mismatch")
}
func TestCallbackHandlerMissingCode(t *testing.T) {
_, res := serveCallback(t, "state123", "state=state123")
require.Error(t, res.err)
assert.Contains(t, res.err.Error(), "no authorization code")
}
func TestCallbackHandlerOAuthError(t *testing.T) {
_, res := serveCallback(t, "state123", "error=access_denied&error_description=user+said+no")
require.Error(t, res.err)
assert.Contains(t, res.err.Error(), "access_denied")
assert.Contains(t, res.err.Error(), "user said no")
}
func TestCallbackHandlerEscapesError(t *testing.T) {
rec, _ := serveCallback(t, "state123", "error=evil&error_description=%3Cscript%3Ealert(1)%3C%2Fscript%3E")
body := rec.Body.String()
assert.NotContains(t, body, "<script>", "error message must be HTML-escaped")
assert.Contains(t, body, "&lt;script&gt;")
}
func TestListenCallbackRandomPortIsLoopback(t *testing.T) {
listener, err := listenCallback(0, false)
require.NoError(t, err)
defer listener.Close()
addr, ok := listener.Addr().(*net.TCPAddr)
require.True(t, ok)
assert.True(t, addr.IP.IsLoopback(), "default bind must be loopback only, got %s", addr.IP)
assert.NotZero(t, addr.Port)
}
func TestListenCallbackBindAllForContainer(t *testing.T) {
listener, err := listenCallback(0, true)
require.NoError(t, err)
defer listener.Close()
addr, ok := listener.Addr().(*net.TCPAddr)
require.True(t, ok)
assert.True(t, addr.IP.IsUnspecified(), "bindAll must bind all interfaces, got %s", addr.IP)
}
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)
}
}
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
}
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)
}
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())
}
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,
}
}
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"))
}
// 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
}
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() }
<!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>
<!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>
package oauth
import (
"context"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"sync"
"testing"
"time"
"golang.org/x/oauth2"
)
// fakeGitHub is an httptest-backed stand-in for GitHub's OAuth endpoints. It
// implements the authorize redirect, the token endpoint (authorization_code,
// refresh_token, and device_code grants), and the device-code endpoint, while
// recording what it received so tests can assert on real protocol behavior
// (PKCE challenge/verifier, grant sequence) rather than re-implementing it.
type fakeGitHub struct {
*httptest.Server
mu sync.Mutex
grants []string // grant_type of each token request, in order
codeChallenge string
codeChallengeMethod string
codeVerifier string
devicePending int // number of authorization_pending responses before success
// Token values returned per grant. A positive expires field is sent as
// expires_in (and makes the token expiring/refreshable).
authToken string
authRefresh string
authExpires int
refreshToken string
refreshExpires int
deviceToken string
}
func newFakeGitHub(t *testing.T) *fakeGitHub {
t.Helper()
f := &fakeGitHub{
authToken: "gho_access",
deviceToken: "gho_device",
}
mux := http.NewServeMux()
mux.HandleFunc("/login/oauth/authorize", f.handleAuthorize)
mux.HandleFunc("/login/oauth/access_token", f.handleToken)
mux.HandleFunc("/login/device/code", f.handleDeviceCode)
f.Server = httptest.NewServer(mux)
t.Cleanup(f.Server.Close)
return f
}
func (f *fakeGitHub) endpoint() oauth2.Endpoint {
return oauth2.Endpoint{
AuthURL: f.URL + "/login/oauth/authorize",
TokenURL: f.URL + "/login/oauth/access_token",
DeviceAuthURL: f.URL + "/login/device/code",
}
}
func (f *fakeGitHub) handleAuthorize(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
f.mu.Lock()
f.codeChallenge = q.Get("code_challenge")
f.codeChallengeMethod = q.Get("code_challenge_method")
f.mu.Unlock()
redirect := q.Get("redirect_uri") + "?code=authcode&state=" + url.QueryEscape(q.Get("state"))
http.Redirect(w, r, redirect, http.StatusFound)
}
func (f *fakeGitHub) handleToken(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
grant := r.Form.Get("grant_type")
f.mu.Lock()
f.grants = append(f.grants, grant)
switch grant {
case "authorization_code":
f.codeVerifier = r.Form.Get("code_verifier")
f.mu.Unlock()
writeToken(w, f.authToken, f.authRefresh, f.authExpires)
case "refresh_token":
f.mu.Unlock()
writeToken(w, f.refreshToken, "", f.refreshExpires)
case "urn:ietf:params:oauth:grant-type:device_code":
pending := f.devicePending
if pending > 0 {
f.devicePending--
}
f.mu.Unlock()
if pending > 0 {
writeJSON(w, http.StatusBadRequest, map[string]any{"error": "authorization_pending"})
return
}
writeToken(w, f.deviceToken, "", 0)
default:
f.mu.Unlock()
http.Error(w, "unsupported grant_type", http.StatusBadRequest)
}
}
func (f *fakeGitHub) handleDeviceCode(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"device_code": "devicecode",
"user_code": "ABCD-1234",
"verification_uri": f.URL + "/device",
"expires_in": 900,
"interval": 1,
})
}
func (f *fakeGitHub) recordedGrants() []string {
f.mu.Lock()
defer f.mu.Unlock()
return append([]string(nil), f.grants...)
}
func writeToken(w http.ResponseWriter, access, refresh string, expiresIn int) {
body := map[string]any{
"access_token": access,
"token_type": "bearer",
}
if refresh != "" {
body["refresh_token"] = refresh
}
if expiresIn != 0 {
body["expires_in"] = expiresIn
}
writeJSON(w, http.StatusOK, body)
}
func writeJSON(w http.ResponseWriter, status int, body map[string]any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(body)
}
// fakePrompter is a configurable Prompter. The on* hooks simulate the user
// acting on the prompt; a nil hook means the prompt is shown and acknowledged.
type fakePrompter struct {
urlCapable bool
formCapable bool
onURL func(context.Context, Prompt) error
onForm func(context.Context, Prompt) error
mu sync.Mutex
urlCalls []Prompt
}
func (p *fakePrompter) CanPromptURL() bool { return p.urlCapable }
func (p *fakePrompter) PromptURL(ctx context.Context, prompt Prompt) error {
p.mu.Lock()
p.urlCalls = append(p.urlCalls, prompt)
p.mu.Unlock()
if p.onURL != nil {
return p.onURL(ctx, prompt)
}
return nil
}
func (p *fakePrompter) CanPromptForm() bool { return p.formCapable }
func (p *fakePrompter) PromptForm(ctx context.Context, prompt Prompt) error {
if p.onForm != nil {
return p.onForm(ctx, prompt)
}
return nil
}
// browserGet simulates a user completing the authorization-code flow by opening
// the URL: it follows the authorize redirect to the local callback, delivering
// the code to the manager's callback server. Used both as an openURL seam and
// inside prompter hooks.
func browserGet(rawurl string) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawurl, nil)
if err != nil {
return err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
_, _ = io.Copy(io.Discard, resp.Body)
return resp.Body.Close()
}
func testLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
// waitForToken polls until the manager has a token or the deadline passes. The
// authorization-code flow completes asynchronously after the callback fires, so
// tests wait for the resulting token rather than sleeping a fixed duration.
func waitForToken(t *testing.T, m *Manager) string {
t.Helper()
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
if tok := m.AccessToken(); tok != "" {
return tok
}
time.Sleep(5 * time.Millisecond)
}
t.Fatal("timed out waiting for access token")
return ""
}

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

package github
import (
"context"
"encoding/json"
"testing"
"github.com/github/github-mcp-server/internal/githubv4mock"
"github.com/github/github-mcp-server/internal/toolsnaps"
"github.com/github/github-mcp-server/pkg/translations"
"github.com/google/jsonschema-go/jsonschema"
"github.com/shurcooL/githubv4"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func Test_IssueDependencyRead(t *testing.T) {
// Verify tool definition once (flag-gated variant snap)
serverTool := IssueDependencyRead(translations.NullTranslationHelper)
tool := serverTool.Tool
require.NoError(t, toolsnaps.Test(tool.Name+"_ff_"+FeatureFlagIssueDependencies, tool))
require.Equal(t, FeatureFlagIssueDependencies, serverTool.FeatureFlagEnable)
assert.Equal(t, "issue_dependency_read", tool.Name)
assert.NotEmpty(t, tool.Description)
assert.True(t, tool.Annotations.ReadOnlyHint)
schema := tool.InputSchema.(*jsonschema.Schema)
assert.Contains(t, schema.Properties, "method")
assert.Contains(t, schema.Properties, "owner")
assert.Contains(t, schema.Properties, "repo")
assert.Contains(t, schema.Properties, "issue_number")
assert.ElementsMatch(t, schema.Required, []string{"method", "owner", "repo", "issue_number"})
blockedByQuery := githubv4mock.NewQueryMatcher(
struct {
Repository struct {
Issue struct {
BlockedBy dependencyConnection `graphql:"blockedBy(first: $first, after: $after)"`
} `graphql:"issue(number: $issueNumber)"`
} `graphql:"repository(owner: $owner, name: $repo)"`
}{},
map[string]any{
"owner": githubv4.String("owner"),
"repo": githubv4.String("repo"),
"issueNumber": githubv4.Int(123),
"first": githubv4.Int(30),
"after": (*githubv4.String)(nil),
},
githubv4mock.DataResponse(map[string]any{
"repository": map[string]any{
"issue": map[string]any{
"blockedBy": map[string]any{
"totalCount": 1,
"pageInfo": map[string]any{
"hasNextPage": false,
"endCursor": "",
},
"nodes": []map[string]any{
{
"number": 7,
"title": "Blocker",
"state": "OPEN",
"url": "https://github.com/owner/repo/issues/7",
"repository": map[string]any{"nameWithOwner": "owner/repo"},
},
},
},
},
},
}),
)
blockingQuery := githubv4mock.NewQueryMatcher(
struct {
Repository struct {
Issue struct {
Blocking dependencyConnection `graphql:"blocking(first: $first, after: $after)"`
} `graphql:"issue(number: $issueNumber)"`
} `graphql:"repository(owner: $owner, name: $repo)"`
}{},
map[string]any{
"owner": githubv4.String("owner"),
"repo": githubv4.String("repo"),
"issueNumber": githubv4.Int(123),
"first": githubv4.Int(30),
"after": (*githubv4.String)(nil),
},
githubv4mock.DataResponse(map[string]any{
"repository": map[string]any{
"issue": map[string]any{
"blocking": map[string]any{
"totalCount": 2,
"pageInfo": map[string]any{
"hasNextPage": true,
"endCursor": "Y3Vyc29y",
},
"nodes": []map[string]any{
{
"number": 8,
"title": "Blocked A",
"state": "OPEN",
"url": "https://github.com/owner/repo/issues/8",
"repository": map[string]any{"nameWithOwner": "owner/repo"},
},
{
"number": 9,
"title": "Blocked B",
"state": "CLOSED",
"url": "https://github.com/owner/repo/issues/9",
"repository": map[string]any{"nameWithOwner": "owner/repo"},
},
},
},
},
},
}),
)
tests := []struct {
name string
method string
matcher githubv4mock.Matcher
expectError bool
expectedCount int
expectedFirst int
expectedNext bool
}{
{
name: "get_blocked_by returns blockers",
method: "get_blocked_by",
matcher: blockedByQuery,
expectedCount: 1,
expectedFirst: 7,
expectedNext: false,
},
{
name: "get_blocking returns blocked issues",
method: "get_blocking",
matcher: blockingQuery,
expectedCount: 2,
expectedFirst: 8,
expectedNext: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(tc.matcher))
deps := BaseDeps{GQLClient: gqlClient}
handler := serverTool.Handler(deps)
request := createMCPRequest(map[string]any{
"method": tc.method,
"owner": "owner",
"repo": "repo",
"issue_number": float64(123),
})
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
require.False(t, result.IsError, "expected result to not be an error")
text := getTextResult(t, result)
var payload struct {
Issues []minimalDependencyIssue `json:"issues"`
Total int `json:"totalCount"`
Page struct {
HasNextPage bool `json:"hasNextPage"`
EndCursor string `json:"endCursor"`
} `json:"pageInfo"`
}
require.NoError(t, json.Unmarshal([]byte(text.Text), &payload))
require.Len(t, payload.Issues, tc.expectedCount)
assert.Equal(t, tc.expectedFirst, payload.Issues[0].Number)
assert.Equal(t, tc.expectedNext, payload.Page.HasNextPage)
})
}
}
func Test_IssueDependencyRead_Errors(t *testing.T) {
serverTool := IssueDependencyRead(translations.NullTranslationHelper)
t.Run("rejects page-based pagination", func(t *testing.T) {
gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient())
deps := BaseDeps{GQLClient: gqlClient}
handler := serverTool.Handler(deps)
request := createMCPRequest(map[string]any{
"method": "get_blocked_by",
"owner": "owner",
"repo": "repo",
"issue_number": float64(1),
"page": float64(2),
})
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
errText := getErrorResult(t, result)
assert.Contains(t, errText.Text, "cursor-based pagination")
})
t.Run("missing required param", func(t *testing.T) {
gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient())
deps := BaseDeps{GQLClient: gqlClient}
handler := serverTool.Handler(deps)
request := createMCPRequest(map[string]any{
"method": "get_blocked_by",
"owner": "owner",
"repo": "repo",
})
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
getErrorResult(t, result)
})
}
func Test_IssueDependencyWrite(t *testing.T) {
// Verify tool definition once (flag-gated variant snap)
serverTool := IssueDependencyWrite(translations.NullTranslationHelper)
tool := serverTool.Tool
require.NoError(t, toolsnaps.Test(tool.Name+"_ff_"+FeatureFlagIssueDependencies, tool))
require.Equal(t, FeatureFlagIssueDependencies, serverTool.FeatureFlagEnable)
assert.Equal(t, "issue_dependency_write", tool.Name)
assert.NotEmpty(t, tool.Description)
assert.False(t, tool.Annotations.ReadOnlyHint)
schema := tool.InputSchema.(*jsonschema.Schema)
assert.Contains(t, schema.Properties, "method")
assert.Contains(t, schema.Properties, "type")
assert.Contains(t, schema.Properties, "issue_number")
assert.Contains(t, schema.Properties, "related_issue_number")
assert.ElementsMatch(t, schema.Required, []string{"method", "type", "owner", "repo", "issue_number", "related_issue_number"})
resolveMatcher := func(subjectID, relatedID string) githubv4mock.Matcher {
return githubv4mock.NewQueryMatcher(
struct {
Subject struct {
Issue struct {
ID githubv4.ID
} `graphql:"issue(number: $subjectNumber)"`
} `graphql:"subject: repository(owner: $subjectOwner, name: $subjectRepo)"`
Related struct {
Issue struct {
ID githubv4.ID
} `graphql:"issue(number: $relatedNumber)"`
} `graphql:"related: repository(owner: $relatedOwner, name: $relatedRepo)"`
}{},
map[string]any{
"subjectOwner": githubv4.String("owner"),
"subjectRepo": githubv4.String("repo"),
"subjectNumber": githubv4.Int(1),
"relatedOwner": githubv4.String("owner"),
"relatedRepo": githubv4.String("repo"),
"relatedNumber": githubv4.Int(2),
},
githubv4mock.DataResponse(map[string]any{
"subject": map[string]any{"issue": map[string]any{"id": subjectID}},
"related": map[string]any{"issue": map[string]any{"id": relatedID}},
}),
)
}
type mutationIssue struct {
Number githubv4.Int
URL githubv4.String
}
addMutation := func(issueID, blockingID string) githubv4mock.Matcher {
return githubv4mock.NewMutationMatcher(
struct {
AddBlockedBy struct {
Issue mutationIssue
BlockingIssue mutationIssue
} `graphql:"addBlockedBy(input: $input)"`
}{},
AddBlockedByInput{IssueID: githubv4.ID(issueID), BlockingIssueID: githubv4.ID(blockingID)},
nil,
githubv4mock.DataResponse(map[string]any{
"addBlockedBy": map[string]any{
"issue": map[string]any{"number": 1, "url": "https://github.com/owner/repo/issues/1"},
"blockingIssue": map[string]any{"number": 2, "url": "https://github.com/owner/repo/issues/2"},
},
}),
)
}
removeMutation := func(issueID, blockingID string) githubv4mock.Matcher {
return githubv4mock.NewMutationMatcher(
struct {
RemoveBlockedBy struct {
Issue mutationIssue
BlockingIssue mutationIssue
} `graphql:"removeBlockedBy(input: $input)"`
}{},
RemoveBlockedByInput{IssueID: githubv4.ID(issueID), BlockingIssueID: githubv4.ID(blockingID)},
nil,
githubv4mock.DataResponse(map[string]any{
"removeBlockedBy": map[string]any{
"issue": map[string]any{"number": 1, "url": "https://github.com/owner/repo/issues/1"},
"blockingIssue": map[string]any{"number": 2, "url": "https://github.com/owner/repo/issues/2"},
},
}),
)
}
tests := []struct {
name string
method string
relationship string
matchers []githubv4mock.Matcher
expectedMessage string
}{
{
name: "add blocked_by uses subject as blocked",
method: "add",
relationship: "blocked_by",
// subject(1) is blocked by related(2): issueId=subject, blockingIssueId=related
matchers: []githubv4mock.Matcher{resolveMatcher("I_subject", "I_related"), addMutation("I_subject", "I_related")},
expectedMessage: "dependency added",
},
{
name: "add blocking swaps roles",
method: "add",
relationship: "blocking",
// subject(1) blocks related(2): issueId=related, blockingIssueId=subject
matchers: []githubv4mock.Matcher{resolveMatcher("I_subject", "I_related"), addMutation("I_related", "I_subject")},
expectedMessage: "dependency added",
},
{
name: "remove blocked_by",
method: "remove",
relationship: "blocked_by",
matchers: []githubv4mock.Matcher{resolveMatcher("I_subject", "I_related"), removeMutation("I_subject", "I_related")},
expectedMessage: "dependency removed",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(tc.matchers...))
deps := BaseDeps{GQLClient: gqlClient}
handler := serverTool.Handler(deps)
request := createMCPRequest(map[string]any{
"method": tc.method,
"type": tc.relationship,
"owner": "owner",
"repo": "repo",
"issue_number": float64(1),
"related_issue_number": float64(2),
})
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
require.False(t, result.IsError, "expected result to not be an error")
text := getTextResult(t, result)
var payload struct {
Message string `json:"message"`
}
require.NoError(t, json.Unmarshal([]byte(text.Text), &payload))
assert.Equal(t, tc.expectedMessage, payload.Message)
})
}
t.Run("self dependency fails before any API call", func(t *testing.T) {
// Register no matchers: the handler must return before resolving node IDs or mutating.
gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient())
deps := BaseDeps{GQLClient: gqlClient}
handler := serverTool.Handler(deps)
request := createMCPRequest(map[string]any{
"method": "add",
"type": "blocked_by",
"owner": "owner",
"repo": "repo",
"issue_number": float64(1),
"related_issue_number": float64(1),
})
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
require.True(t, result.IsError, "expected result to be an error")
text := getTextResult(t, result)
assert.Contains(t, text.Text, "itself")
})
}
func Test_IssueDependencyWrite_Validation(t *testing.T) {
serverTool := IssueDependencyWrite(translations.NullTranslationHelper)
cases := []struct {
name string
args map[string]any
}{
{
name: "unknown type",
args: map[string]any{
"method": "add",
"type": "related_to",
"owner": "owner",
"repo": "repo",
"issue_number": float64(1),
"related_issue_number": float64(2),
},
},
{
name: "missing related_issue_number",
args: map[string]any{
"method": "add",
"type": "blocked_by",
"owner": "owner",
"repo": "repo",
"issue_number": float64(1),
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient())
deps := BaseDeps{GQLClient: gqlClient}
handler := serverTool.Handler(deps)
request := createMCPRequest(tc.args)
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
getErrorResult(t, result)
})
}
}
package github
import (
"context"
"fmt"
"strings"
ghErrors "github.com/github/github-mcp-server/pkg/errors"
"github.com/github/github-mcp-server/pkg/inventory"
"github.com/github/github-mcp-server/pkg/scopes"
"github.com/github/github-mcp-server/pkg/translations"
"github.com/github/github-mcp-server/pkg/utils"
"github.com/google/jsonschema-go/jsonschema"
"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/shurcooL/githubv4"
)
// AddBlockedByInput represents the input for the addBlockedBy GraphQL mutation.
// The pinned githubv4 library predates the dependency mutations, so the input
// type is declared here. The Go type name must match the GraphQL input type name.
type AddBlockedByInput struct {
IssueID githubv4.ID `json:"issueId"`
BlockingIssueID githubv4.ID `json:"blockingIssueId"`
ClientMutationID *githubv4.String `json:"clientMutationId,omitempty"`
}
// RemoveBlockedByInput represents the input for the removeBlockedBy GraphQL mutation.
type RemoveBlockedByInput struct {
IssueID githubv4.ID `json:"issueId"`
BlockingIssueID githubv4.ID `json:"blockingIssueId"`
ClientMutationID *githubv4.String `json:"clientMutationId,omitempty"`
}
// dependencyIssueNode is the minimal projection returned for each related issue
// in a blocked-by / blocking listing.
type dependencyIssueNode struct {
Number githubv4.Int
Title githubv4.String
State githubv4.String
URL githubv4.String
Repository struct {
NameWithOwner githubv4.String
}
}
// dependencyConnection mirrors the shape of an IssueConnection returned by the
// blockedBy / blocking fields.
type dependencyConnection struct {
TotalCount githubv4.Int
PageInfo struct {
HasNextPage githubv4.Boolean
EndCursor githubv4.String
}
Nodes []dependencyIssueNode
}
// minimalDependencyIssue is the JSON-serialised form of a related issue.
type minimalDependencyIssue struct {
Number int `json:"number"`
Title string `json:"title"`
State string `json:"state"`
URL string `json:"url"`
Repository string `json:"repository"`
}
// IssueDependencyRead creates a tool to read an issue's blocked-by and blocking
// relationships. It is a separate, feature-flagged tool (rather than a method on
// the default issue_read) so the whole dependency capability can be gated as a
// unit without enlarging the default issue tool surface.
func IssueDependencyRead(t translations.TranslationHelperFunc) inventory.ServerTool {
schema := &jsonschema.Schema{
Type: "object",
Properties: map[string]*jsonschema.Schema{
"method": {
Type: "string",
Description: `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.
`,
Enum: []any{"get_blocked_by", "get_blocking"},
},
"owner": {
Type: "string",
Description: "The owner of the repository",
},
"repo": {
Type: "string",
Description: "The name of the repository",
},
"issue_number": {
Type: "number",
Description: "The number of the issue",
},
},
Required: []string{"method", "owner", "repo", "issue_number"},
}
WithCursorPagination(schema)
st := NewTool(
ToolsetMetadataIssues,
mcp.Tool{
Name: "issue_dependency_read",
Description: t("TOOL_ISSUE_DEPENDENCY_READ_DESCRIPTION", "Read an issue's dependency relationships in a GitHub repository: the issues that block it (blocked_by) or the issues it blocks (blocking)."),
Annotations: &mcp.ToolAnnotations{
Title: t("TOOL_ISSUE_DEPENDENCY_READ_USER_TITLE", "Read issue dependencies"),
ReadOnlyHint: true,
},
InputSchema: schema,
},
[]scopes.Scope{scopes.Repo},
func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
method, err := RequiredParam[string](args, "method")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
owner, err := RequiredParam[string](args, "owner")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
repo, err := RequiredParam[string](args, "repo")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
issueNumber, err := RequiredInt(args, "issue_number")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
if _, pageProvided := args["page"]; pageProvided {
return utils.NewToolResultError("This tool uses cursor-based pagination. Use the 'after' parameter with the 'endCursor' value from the previous response instead of 'page'."), nil, nil
}
pagination, err := OptionalCursorPaginationParams(args)
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
gqlPagination, err := pagination.ToGraphQLParams()
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
gqlClient, err := deps.GetGQLClient(ctx)
if err != nil {
return utils.NewToolResultErrorFromErr("failed to get GitHub GraphQL client", err), nil, nil
}
switch method {
case "get_blocked_by":
result, err := GetIssueBlockedBy(ctx, gqlClient, owner, repo, issueNumber, gqlPagination)
return result, nil, err
case "get_blocking":
result, err := GetIssueBlocking(ctx, gqlClient, owner, repo, issueNumber, gqlPagination)
return result, nil, err
default:
return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil
}
})
st.FeatureFlagEnable = FeatureFlagIssueDependencies
return st
}
func dependencyQueryVars(owner, repo string, issueNumber int, pagination *GraphQLPaginationParams) map[string]any {
vars := map[string]any{
"owner": githubv4.String(owner),
"repo": githubv4.String(repo),
"issueNumber": githubv4.Int(issueNumber), // #nosec G115 - issue numbers are always small positive integers
"first": githubv4.Int(*pagination.First),
}
if pagination.After != nil {
vars["after"] = githubv4.String(*pagination.After)
} else {
vars["after"] = (*githubv4.String)(nil)
}
return vars
}
func dependencyResult(conn dependencyConnection) *mcp.CallToolResult {
issues := make([]minimalDependencyIssue, 0, len(conn.Nodes))
for _, node := range conn.Nodes {
issues = append(issues, minimalDependencyIssue{
Number: int(node.Number),
Title: string(node.Title),
State: string(node.State),
URL: string(node.URL),
Repository: string(node.Repository.NameWithOwner),
})
}
return MarshalledTextResult(map[string]any{
"issues": issues,
"totalCount": int(conn.TotalCount),
"pageInfo": map[string]any{
"hasNextPage": bool(conn.PageInfo.HasNextPage),
"endCursor": string(conn.PageInfo.EndCursor),
},
})
}
// GetIssueBlockedBy lists the issues that block the given issue.
func GetIssueBlockedBy(ctx context.Context, client *githubv4.Client, owner, repo string, issueNumber int, pagination *GraphQLPaginationParams) (*mcp.CallToolResult, error) {
var query struct {
Repository struct {
Issue struct {
BlockedBy dependencyConnection `graphql:"blockedBy(first: $first, after: $after)"`
} `graphql:"issue(number: $issueNumber)"`
} `graphql:"repository(owner: $owner, name: $repo)"`
}
if err := client.Query(ctx, &query, dependencyQueryVars(owner, repo, issueNumber, pagination)); err != nil {
return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to get blocked-by issues", err), nil
}
return dependencyResult(query.Repository.Issue.BlockedBy), nil
}
// GetIssueBlocking lists the issues that the given issue blocks.
func GetIssueBlocking(ctx context.Context, client *githubv4.Client, owner, repo string, issueNumber int, pagination *GraphQLPaginationParams) (*mcp.CallToolResult, error) {
var query struct {
Repository struct {
Issue struct {
Blocking dependencyConnection `graphql:"blocking(first: $first, after: $after)"`
} `graphql:"issue(number: $issueNumber)"`
} `graphql:"repository(owner: $owner, name: $repo)"`
}
if err := client.Query(ctx, &query, dependencyQueryVars(owner, repo, issueNumber, pagination)); err != nil {
return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to get blocking issues", err), nil
}
return dependencyResult(query.Repository.Issue.Blocking), nil
}
// IssueDependencyWrite creates a tool to add or remove an issue dependency
// (blocked-by / blocking) relationship. It accepts issue numbers and resolves
// them to GraphQL node IDs before calling the addBlockedBy / removeBlockedBy
// mutations. "blocking" is the inverse of "blocked_by", so both directions are
// served by the same mutation pair with the issue arguments swapped.
func IssueDependencyWrite(t translations.TranslationHelperFunc) inventory.ServerTool {
st := NewTool(
ToolsetMetadataIssues,
mcp.Tool{
Name: "issue_dependency_write",
Description: t("TOOL_ISSUE_DEPENDENCY_WRITE_DESCRIPTION",
"Add or remove an issue dependency relationship in a GitHub repository. "+
"Use type 'blocked_by' to record that the subject issue is blocked by a related issue, "+
"or type 'blocking' to record that the subject issue blocks a related issue. "+
"The related issue defaults to the same repository as the subject unless related_owner/related_repo are provided."),
Annotations: &mcp.ToolAnnotations{
Title: t("TOOL_ISSUE_DEPENDENCY_WRITE_USER_TITLE", "Change issue dependency"),
ReadOnlyHint: false,
},
InputSchema: &jsonschema.Schema{
Type: "object",
Properties: map[string]*jsonschema.Schema{
"method": {
Type: "string",
Description: `The action to perform.
Options are:
- 'add' - create the dependency relationship.
- 'remove' - delete the dependency relationship.`,
Enum: []any{"add", "remove"},
},
"type": {
Type: "string",
Description: `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.`,
Enum: []any{"blocked_by", "blocking"},
},
"owner": {
Type: "string",
Description: "The owner of the subject issue's repository",
},
"repo": {
Type: "string",
Description: "The name of the subject issue's repository",
},
"issue_number": {
Type: "number",
Description: "The number of the subject issue",
},
"related_issue_number": {
Type: "number",
Description: "The number of the related issue to link or unlink",
},
"related_owner": {
Type: "string",
Description: "The owner of the related issue's repository. Defaults to 'owner' when omitted.",
},
"related_repo": {
Type: "string",
Description: "The name of the related issue's repository. Defaults to 'repo' when omitted.",
},
},
Required: []string{"method", "type", "owner", "repo", "issue_number", "related_issue_number"},
},
},
[]scopes.Scope{scopes.Repo},
func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
method, err := RequiredParam[string](args, "method")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
relationshipType, err := RequiredParam[string](args, "type")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
owner, err := RequiredParam[string](args, "owner")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
repo, err := RequiredParam[string](args, "repo")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
issueNumber, err := RequiredInt(args, "issue_number")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
relatedIssueNumber, err := RequiredInt(args, "related_issue_number")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
relatedOwner, err := OptionalParam[string](args, "related_owner")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
relatedRepo, err := OptionalParam[string](args, "related_repo")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
if relatedOwner == "" {
relatedOwner = owner
}
if relatedRepo == "" {
relatedRepo = repo
}
method = strings.ToLower(method)
relationshipType = strings.ToLower(relationshipType)
if method != "add" && method != "remove" {
return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil
}
if relationshipType != "blocked_by" && relationshipType != "blocking" {
return utils.NewToolResultError(fmt.Sprintf("unknown type: %s", relationshipType)), nil, nil
}
if owner == relatedOwner && repo == relatedRepo && issueNumber == relatedIssueNumber {
return utils.NewToolResultError("an issue cannot block or depend on itself"), nil, nil
}
gqlClient, err := deps.GetGQLClient(ctx)
if err != nil {
return utils.NewToolResultErrorFromErr("failed to get GitHub GraphQL client", err), nil, nil
}
subjectID, relatedID, err := resolveIssueNodeIDs(ctx, gqlClient, owner, repo, issueNumber, relatedOwner, relatedRepo, relatedIssueNumber)
if err != nil {
return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to resolve issues", err), nil, nil
}
// "A blocks B" is recorded as addBlockedBy(issueId: B, blockingIssueId: A).
// For type 'blocked_by' the subject is blocked by the related issue.
// For type 'blocking' the subject blocks the related issue, so the roles swap.
blockedID, blockingID := subjectID, relatedID
if relationshipType == "blocking" {
blockedID, blockingID = relatedID, subjectID
}
result, err := writeBlockedByRelationship(ctx, gqlClient, method, blockedID, blockingID)
return result, nil, err
})
st.FeatureFlagEnable = FeatureFlagIssueDependencies
return st
}
// resolveIssueNodeIDs resolves the subject and related issue numbers to their
// GraphQL node IDs in a single aliased query.
func resolveIssueNodeIDs(ctx context.Context, client *githubv4.Client, owner, repo string, issueNumber int, relatedOwner, relatedRepo string, relatedIssueNumber int) (githubv4.ID, githubv4.ID, error) {
var query struct {
Subject struct {
Issue struct {
ID githubv4.ID
} `graphql:"issue(number: $subjectNumber)"`
} `graphql:"subject: repository(owner: $subjectOwner, name: $subjectRepo)"`
Related struct {
Issue struct {
ID githubv4.ID
} `graphql:"issue(number: $relatedNumber)"`
} `graphql:"related: repository(owner: $relatedOwner, name: $relatedRepo)"`
}
vars := map[string]any{
"subjectOwner": githubv4.String(owner),
"subjectRepo": githubv4.String(repo),
"subjectNumber": githubv4.Int(issueNumber), // #nosec G115 - issue numbers are always small positive integers
"relatedOwner": githubv4.String(relatedOwner),
"relatedRepo": githubv4.String(relatedRepo),
"relatedNumber": githubv4.Int(relatedIssueNumber), // #nosec G115 - issue numbers are always small positive integers
}
if err := client.Query(ctx, &query, vars); err != nil {
return "", "", err
}
return query.Subject.Issue.ID, query.Related.Issue.ID, nil
}
// writeBlockedByRelationship runs the addBlockedBy / removeBlockedBy mutation and
// returns a minimal description of the affected issues.
func writeBlockedByRelationship(ctx context.Context, client *githubv4.Client, method string, blockedID, blockingID githubv4.ID) (*mcp.CallToolResult, error) {
type mutationIssue struct {
Number githubv4.Int
URL githubv4.String
}
switch method {
case "add":
var mutation struct {
AddBlockedBy struct {
Issue mutationIssue
BlockingIssue mutationIssue
} `graphql:"addBlockedBy(input: $input)"`
}
input := AddBlockedByInput{IssueID: blockedID, BlockingIssueID: blockingID}
if err := client.Mutate(ctx, &mutation, input, nil); err != nil {
return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to add issue dependency", err), nil
}
return MarshalledTextResult(map[string]any{
"message": "dependency added",
"blocked_issue": map[string]any{"number": int(mutation.AddBlockedBy.Issue.Number), "url": string(mutation.AddBlockedBy.Issue.URL)},
"blocking_issue": map[string]any{"number": int(mutation.AddBlockedBy.BlockingIssue.Number), "url": string(mutation.AddBlockedBy.BlockingIssue.URL)},
}), nil
case "remove":
var mutation struct {
RemoveBlockedBy struct {
Issue mutationIssue
BlockingIssue mutationIssue
} `graphql:"removeBlockedBy(input: $input)"`
}
input := RemoveBlockedByInput{IssueID: blockedID, BlockingIssueID: blockingID}
if err := client.Mutate(ctx, &mutation, input, nil); err != nil {
return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to remove issue dependency", err), nil
}
return MarshalledTextResult(map[string]any{
"message": "dependency removed",
"blocked_issue": map[string]any{"number": int(mutation.RemoveBlockedBy.Issue.Number), "url": string(mutation.RemoveBlockedBy.Issue.URL)},
"blocking_issue": map[string]any{"number": int(mutation.RemoveBlockedBy.BlockingIssue.Number), "url": string(mutation.RemoveBlockedBy.BlockingIssue.URL)},
}), nil
default:
return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil
}
}
package github
import (
"context"
"encoding/json"
"io"
"net/http"
"strings"
"sync"
"testing"
"github.com/github/github-mcp-server/internal/githubv4mock"
gogithub "github.com/google/go-github/v87/github"
"github.com/shurcooL/githubv4"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// Test_IssueRequest_EmptyFieldValues_OmittedByJSON pins the omitempty
// behaviour that makes the DELETE fallback necessary. If go-github ever drops
// the tag, the REST PATCH alone could clear field values and this test would
// fail to remind us.
func Test_IssueRequest_EmptyFieldValues_OmittedByJSON(t *testing.T) {
t.Parallel()
req := &gogithub.IssueRequest{
Title: gogithub.Ptr("still here"),
IssueFieldValues: []*gogithub.IssueRequestFieldValue{},
}
body, err := json.Marshal(req)
require.NoError(t, err)
assert.NotContains(t, string(body), "issue_field_values",
"empty IssueFieldValues should be dropped by omitempty — this is why the REST PATCH alone can't clear field values when the merged list ends up empty, and why we fall back to the dedicated DELETE endpoint")
assert.Contains(t, string(body), `"title":"still here"`,
"sanity check: other fields still serialise")
}
// Test_UpdateIssue_DeleteLastFieldValueCallsDeleteEndpoint covers the bug fix:
// when the kept set ends up empty, the PATCH alone can't clear the field
// (omitempty strips the empty slice), so UpdateIssue follows up with a DELETE
// to the dedicated endpoint.
func Test_UpdateIssue_DeleteLastFieldValueCallsDeleteEndpoint(t *testing.T) {
t.Parallel()
mockIssue := &gogithub.Issue{
Number: gogithub.Ptr(42),
Title: gogithub.Ptr("Test issue"),
State: gogithub.Ptr("open"),
HTMLURL: gogithub.Ptr("https://github.com/owner/repo/issues/42"),
}
var (
mu sync.Mutex
capturedPatchBody []byte
deletePaths []string
)
restClient := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
PatchReposIssuesByOwnerByRepoByIssueNumber: func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
mu.Lock()
capturedPatchBody = body
mu.Unlock()
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(mockIssue)
},
DeleteReposIssuesIssueFieldValueByOwnerByRepoByIssueNumber: func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
deletePaths = append(deletePaths, r.URL.Path)
mu.Unlock()
w.WriteHeader(http.StatusNoContent)
},
}))
// Existing field values for the merge step. Returning the field we're
// about to delete makes the kept list empty, triggering the fallback DELETE.
existingFieldsResponse := githubv4mock.DataResponse(map[string]any{
"repository": map[string]any{
"issue": map[string]any{
"issueFieldValues": map[string]any{
"nodes": []any{
map[string]any{
"__typename": "IssueFieldSingleSelectValue",
"field": map[string]any{
"fullDatabaseId": "101",
"name": "Priority",
},
"value": "P1",
},
},
},
},
},
})
gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(
githubv4mock.NewQueryMatcher(
struct {
Repository struct {
Issue struct {
IssueFieldValues struct {
Nodes []IssueFieldValueFragment
} `graphql:"issueFieldValues(first: 25)"`
} `graphql:"issue(number: $number)"`
} `graphql:"repository(owner: $owner, name: $repo)"`
}{},
map[string]any{
"owner": githubv4.String("owner"),
"repo": githubv4.String("repo"),
"number": githubv4.Int(42),
},
existingFieldsResponse,
),
))
result, err := UpdateIssue(
context.Background(),
restClient,
gqlClient,
"owner", "repo", 42,
"", "", nil, nil, 0, "",
nil,
[]int64{101},
"", "", 0,
)
require.NoError(t, err)
if result.IsError {
t.Fatalf("expected non-error result, got: %s", getTextResult(t, result).Text)
}
mu.Lock()
defer mu.Unlock()
require.NotContains(t, string(capturedPatchBody), "issue_field_values",
"REST PATCH body must not carry issue_field_values when the kept set is empty (PATCH body was: %s)", string(capturedPatchBody))
require.Equal(t, []string{"/repos/owner/repo/issues/42/issue-field-values/101"}, deletePaths,
"expected exactly one DELETE call to the dedicated endpoint for field id 101")
}
// Test_UpdateIssue_DeleteOneOfManyUsesSetSemantics: when the kept set is
// non-empty, set semantics handle the deletion implicitly via the PATCH — no
// DELETE follow-up needed.
func Test_UpdateIssue_DeleteOneOfManyUsesSetSemantics(t *testing.T) {
t.Parallel()
mockIssue := &gogithub.Issue{
Number: gogithub.Ptr(42),
Title: gogithub.Ptr("Test issue"),
State: gogithub.Ptr("open"),
HTMLURL: gogithub.Ptr("https://github.com/owner/repo/issues/42"),
}
var (
mu sync.Mutex
deletePaths []string
)
restClient := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
PatchReposIssuesByOwnerByRepoByIssueNumber: expectRequestBody(t, map[string]any{
"issue_field_values": []any{
map[string]any{"field_id": float64(202), "value": "High"},
},
}).andThen(
mockResponse(t, http.StatusOK, mockIssue),
),
DeleteReposIssuesIssueFieldValueByOwnerByRepoByIssueNumber: func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
deletePaths = append(deletePaths, r.URL.Path)
mu.Unlock()
w.WriteHeader(http.StatusNoContent)
},
}))
existingFieldsResponse := githubv4mock.DataResponse(map[string]any{
"repository": map[string]any{
"issue": map[string]any{
"issueFieldValues": map[string]any{
"nodes": []any{
map[string]any{
"__typename": "IssueFieldSingleSelectValue",
"field": map[string]any{"fullDatabaseId": "101", "name": "Priority"},
"value": "P1",
},
map[string]any{
"__typename": "IssueFieldSingleSelectValue",
"field": map[string]any{"fullDatabaseId": "202", "name": "Impact"},
"value": "High",
},
},
},
},
},
})
gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(
githubv4mock.NewQueryMatcher(
struct {
Repository struct {
Issue struct {
IssueFieldValues struct {
Nodes []IssueFieldValueFragment
} `graphql:"issueFieldValues(first: 25)"`
} `graphql:"issue(number: $number)"`
} `graphql:"repository(owner: $owner, name: $repo)"`
}{},
map[string]any{
"owner": githubv4.String("owner"),
"repo": githubv4.String("repo"),
"number": githubv4.Int(42),
},
existingFieldsResponse,
),
))
result, err := UpdateIssue(
context.Background(),
restClient,
gqlClient,
"owner", "repo", 42,
"", "", nil, nil, 0, "",
nil,
[]int64{101},
"", "", 0,
)
require.NoError(t, err)
if result.IsError {
t.Fatalf("expected non-error result, got: %s", getTextResult(t, result).Text)
}
mu.Lock()
defer mu.Unlock()
require.Empty(t, deletePaths,
"no DELETE call should fire when the kept set is non-empty — the PATCH's set semantics clear the deleted field on the server side")
}
// Test_UpdateIssue_DeleteAbsentFieldIsNoOp: deleting a field that isn't set
// must not fire a DELETE (the endpoint would 404), preserving the pre-fix
// silent-no-op behaviour so idempotent delete:true callers don't break on
// retry.
func Test_UpdateIssue_DeleteAbsentFieldIsNoOp(t *testing.T) {
t.Parallel()
mockIssue := &gogithub.Issue{
Number: gogithub.Ptr(42),
Title: gogithub.Ptr("Test issue"),
State: gogithub.Ptr("open"),
HTMLURL: gogithub.Ptr("https://github.com/owner/repo/issues/42"),
}
var (
mu sync.Mutex
capturedPatchBody []byte
deletePaths []string
)
restClient := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
PatchReposIssuesByOwnerByRepoByIssueNumber: func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
mu.Lock()
capturedPatchBody = body
mu.Unlock()
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(mockIssue)
},
DeleteReposIssuesIssueFieldValueByOwnerByRepoByIssueNumber: func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
deletePaths = append(deletePaths, r.URL.Path)
mu.Unlock()
// Fail loudly: if we get here, the fix is wrong.
w.WriteHeader(http.StatusNotFound)
},
}))
// Issue has no field values at all.
existingFieldsResponse := githubv4mock.DataResponse(map[string]any{
"repository": map[string]any{
"issue": map[string]any{
"issueFieldValues": map[string]any{
"nodes": []any{},
},
},
},
})
gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(
githubv4mock.NewQueryMatcher(
struct {
Repository struct {
Issue struct {
IssueFieldValues struct {
Nodes []IssueFieldValueFragment
} `graphql:"issueFieldValues(first: 25)"`
} `graphql:"issue(number: $number)"`
} `graphql:"repository(owner: $owner, name: $repo)"`
}{},
map[string]any{
"owner": githubv4.String("owner"),
"repo": githubv4.String("repo"),
"number": githubv4.Int(42),
},
existingFieldsResponse,
),
))
result, err := UpdateIssue(
context.Background(),
restClient,
gqlClient,
"owner", "repo", 42,
"", "", nil, nil, 0, "",
nil,
[]int64{101}, // ask to delete a field that isn't set
"", "", 0,
)
require.NoError(t, err)
if result.IsError {
t.Fatalf("expected non-error result, got: %s", getTextResult(t, result).Text)
}
mu.Lock()
defer mu.Unlock()
require.NotContains(t, string(capturedPatchBody), "issue_field_values",
"PATCH body must not carry issue_field_values when nothing changed")
require.Empty(t, deletePaths,
"no DELETE call should fire for a field that isn't present on the issue — preserves the pre-fix silent-no-op behaviour and avoids a guaranteed 404")
}
// Test_UpdateIssue_DeleteFallbackContinuesOnPartialFailure: a failing DELETE
// must not short-circuit subsequent ones, and the error must name which IDs
// succeeded and which failed so callers can retry the right ones.
func Test_UpdateIssue_DeleteFallbackContinuesOnPartialFailure(t *testing.T) {
t.Parallel()
mockIssue := &gogithub.Issue{
Number: gogithub.Ptr(42),
Title: gogithub.Ptr("Test issue"),
State: gogithub.Ptr("open"),
HTMLURL: gogithub.Ptr("https://github.com/owner/repo/issues/42"),
}
var (
mu sync.Mutex
deletePaths []string
)
restClient := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
PatchReposIssuesByOwnerByRepoByIssueNumber: func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(mockIssue)
},
DeleteReposIssuesIssueFieldValueByOwnerByRepoByIssueNumber: func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
deletePaths = append(deletePaths, r.URL.Path)
mu.Unlock()
// Field 202 fails; 101 and 303 succeed. All three should fire and
// the error must name 202 as failed and 101/303 as cleared.
if strings.HasSuffix(r.URL.Path, "/202") {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"message":"simulated failure"}`))
return
}
w.WriteHeader(http.StatusNoContent)
},
}))
existingFieldsResponse := githubv4mock.DataResponse(map[string]any{
"repository": map[string]any{
"issue": map[string]any{
"issueFieldValues": map[string]any{
"nodes": []any{
map[string]any{
"__typename": "IssueFieldSingleSelectValue",
"field": map[string]any{"fullDatabaseId": "101", "name": "Priority"},
"value": "P1",
},
map[string]any{
"__typename": "IssueFieldSingleSelectValue",
"field": map[string]any{"fullDatabaseId": "202", "name": "Visibility"},
"value": "High",
},
map[string]any{
"__typename": "IssueFieldSingleSelectValue",
"field": map[string]any{"fullDatabaseId": "303", "name": "Impact"},
"value": "Critical",
},
},
},
},
},
})
gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(
githubv4mock.NewQueryMatcher(
struct {
Repository struct {
Issue struct {
IssueFieldValues struct {
Nodes []IssueFieldValueFragment
} `graphql:"issueFieldValues(first: 25)"`
} `graphql:"issue(number: $number)"`
} `graphql:"repository(owner: $owner, name: $repo)"`
}{},
map[string]any{
"owner": githubv4.String("owner"),
"repo": githubv4.String("repo"),
"number": githubv4.Int(42),
},
existingFieldsResponse,
),
))
result, err := UpdateIssue(
context.Background(),
restClient,
gqlClient,
"owner", "repo", 42,
"", "", nil, nil, 0, "",
nil,
[]int64{101, 202, 303},
"", "", 0,
)
require.NoError(t, err)
require.True(t, result.IsError, "expected an error result because field 202 failed")
mu.Lock()
defer mu.Unlock()
// All three DELETEs must have fired — the middle failure must not short-circuit the third.
require.Len(t, deletePaths, 3,
"all three DELETE calls should fire even though one fails; got paths: %v", deletePaths)
resultText := getTextResult(t, result).Text
require.Contains(t, resultText, "failed=[202]",
"error must name the failed field ID so the caller can retry it; got: %s", resultText)
require.Contains(t, resultText, "cleared=[101 303]",
"error must name the cleared field IDs so the caller knows what's already done; got: %s", resultText)
}
package transport
import (
"context"
"net/http"
"net/http/httptest"
"testing"
ghcontext "github.com/github/github-mcp-server/pkg/context"
"github.com/github/github-mcp-server/pkg/http/headers"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestBearerAuthTransport(t *testing.T) {
t.Parallel()
tests := []struct {
name string
token string
tokenProvider func() string
wantAuth string
}{
{
name: "static token",
token: "static-token",
wantAuth: "Bearer static-token",
},
{
name: "token provider takes precedence over static token",
token: "static-token",
tokenProvider: func() string { return "provided-token" },
wantAuth: "Bearer provided-token",
},
{
name: "token provider with empty static token",
tokenProvider: func() string { return "provided-token" },
wantAuth: "Bearer provided-token",
},
{
name: "token provider may return empty before authorization",
tokenProvider: func() string { return "" },
wantAuth: "",
},
}
for _, tc := range tests {
t.Run(tc.name, func(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()
rt := &BearerAuthTransport{
Transport: http.DefaultTransport,
Token: tc.token,
TokenProvider: tc.tokenProvider,
}
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, server.URL, nil)
require.NoError(t, err)
resp, err := rt.RoundTrip(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, tc.wantAuth, gotAuth)
})
}
}
// TestBearerAuthTransport_TokenProviderResolvedPerRequest verifies that the
// token provider is consulted on every request, so a token that arrives (or is
// refreshed) after the transport is constructed takes effect without rebuilding
// the client. This is the property OAuth relies on.
func TestBearerAuthTransport_TokenProviderResolvedPerRequest(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 := ""
rt := &BearerAuthTransport{
Transport: http.DefaultTransport,
TokenProvider: func() string { return current },
}
do := func() {
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, server.URL, nil)
require.NoError(t, err)
resp, err := rt.RoundTrip(req)
require.NoError(t, err)
defer resp.Body.Close()
}
do()
assert.Equal(t, "", gotAuth, "no auth header before authorization")
current = "first-token"
do()
assert.Equal(t, "Bearer first-token", gotAuth, "token picked up once available")
current = "refreshed-token"
do()
assert.Equal(t, "Bearer refreshed-token", gotAuth, "refreshed token picked up")
}
func TestBearerAuthTransport_PassesGraphQLFeaturesHeader(t *testing.T) {
t.Parallel()
var gotFeatures string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotFeatures = r.Header.Get(headers.GraphQLFeaturesHeader)
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
rt := &BearerAuthTransport{
Transport: http.DefaultTransport,
Token: "token",
}
ctx := ghcontext.WithGraphQLFeatures(context.Background(), "feature1", "feature2")
req, err := http.NewRequestWithContext(ctx, http.MethodPost, server.URL, nil)
require.NoError(t, err)
resp, err := rt.RoundTrip(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, "feature1, feature2", gotFeatures)
}
func TestBearerAuthTransport_DoesNotMutateOriginalRequest(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
rt := &BearerAuthTransport{
Transport: http.DefaultTransport,
Token: "token",
}
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, server.URL, nil)
require.NoError(t, err)
resp, err := rt.RoundTrip(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Empty(t, req.Header.Get(headers.AuthorizationHeader), "original request must not be mutated")
}
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
/**
* Returns the parsed JSON payload of a *completed* tool result, or `null` when
* the result is absent, an error, or the deferral sentinel (structured
* `status` of `"awaiting_user_submission"`, set by the server's
* `NewToolResultAwaitingFormSubmission`).
*
* Form-backed write Views (create_pull_request, issue_write,
* update_pull_request) use this to tell apart "the server deferred and is
* waiting for my form submission" from "the server already executed". Without
* it, a View renders its input form off in-app state alone and will show e.g. a
* "Create pull request" form for a PR that was already created up-front (when
* the agent passed `show_ui=false` or parameters the form can't represent).
*
* See github/copilot-mcp-core#1864 for the full show/defer state machine.
*/
export function completedToolResult<T = Record<string, unknown>>(
result: CallToolResult | null,
): T | null {
if (!result || result.isError) return null;
const status = (result.structuredContent as { status?: string } | undefined)
?.status;
if (status === "awaiting_user_submission") return null;
const textContent = result.content?.find((c) => c.type === "text");
if (!textContent || textContent.type !== "text" || !textContent.text) {
return null;
}
try {
return JSON.parse(textContent.text) as T;
} catch {
return null;
}
}
+1
-1

@@ -17,3 +17,3 @@ name: AI Issue Assessment

- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7

@@ -20,0 +20,0 @@ - name: Run AI assessment

@@ -44,3 +44,3 @@ name: "CodeQL"

- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v7

@@ -47,0 +47,0 @@ - name: Initialize CodeQL

@@ -43,3 +43,3 @@ name: Docker

- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v7

@@ -121,2 +121,5 @@ # Install the cosign tool except on PR

VERSION=${{ github.ref_name }}
secrets: |
oauth_client_id=${{ secrets.OAUTH_CLIENT_ID }}
oauth_client_secret=${{ secrets.OAUTH_CLIENT_SECRET }}

@@ -123,0 +126,0 @@ # Sign the resulting Docker image digest except on PRs.

@@ -17,3 +17,3 @@ name: Documentation Check

- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v7

@@ -20,0 +20,0 @@ - name: Build UI

@@ -26,3 +26,3 @@ name: Build and Test Go Project

- name: Check out code
uses: actions/checkout@v6
uses: actions/checkout@v7

@@ -29,0 +29,0 @@ - name: Build UI

@@ -17,3 +17,3 @@ name: GoReleaser Release

- name: Check out code
uses: actions/checkout@v6
uses: actions/checkout@v7

@@ -42,2 +42,4 @@ - name: Build UI

GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
OAUTH_CLIENT_ID: ${{ secrets.OAUTH_CLIENT_ID }}
OAUTH_CLIENT_SECRET: ${{ secrets.OAUTH_CLIENT_SECRET }}

@@ -44,0 +46,0 @@ - name: Generate signed build provenance attestations for workflow artifacts

@@ -27,3 +27,3 @@ # Automatically fix license files on PRs that need updates

- name: Check out code
uses: actions/checkout@v6
uses: actions/checkout@v7

@@ -30,0 +30,0 @@ # Check out the actual PR branch so we can push changes back if needed

@@ -16,3 +16,3 @@ name: golangci-lint

steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Build UI

@@ -19,0 +19,0 @@ uses: ./.github/actions/build-ui

@@ -18,3 +18,3 @@ name: MCP Server Diff

- name: Check out code
uses: actions/checkout@v6
uses: actions/checkout@v7
with:

@@ -84,3 +84,3 @@ fetch-depth: 0

- name: Check out code
uses: actions/checkout@v6
uses: actions/checkout@v7
with:

@@ -87,0 +87,0 @@ fetch-depth: 0

@@ -19,3 +19,3 @@ name: AI Moderator

steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: github/ai-moderator@v1

@@ -22,0 +22,0 @@ with:

@@ -17,3 +17,3 @@ name: Publish to MCP Registry

- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v7

@@ -20,0 +20,0 @@ - name: Setup Go

@@ -12,3 +12,3 @@ version: 2

ldflags:
- -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}
- -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:

@@ -15,0 +15,0 @@ - linux

@@ -268,4 +268,2 @@ package main

conditional := inventory.ConditionalSchemaPropertyDescriptions()
for i, propName := range paramNames {

@@ -296,7 +294,3 @@ prop := schema.Properties[propName]

if cond, isConditional := conditional[propName]; isConditional {
fmt.Fprintf(buf, " - `%s`: %s (%s, %s, conditional — %s)", propName, description, typeStr, requiredStr, cond)
} else {
fmt.Fprintf(buf, " - `%s`: %s (%s, %s)", propName, description, typeStr, requiredStr)
}
fmt.Fprintf(buf, " - `%s`: %s (%s, %s)", propName, description, typeStr, requiredStr)
if i < len(paramNames)-1 {

@@ -303,0 +297,0 @@ buf.WriteString("\n")

@@ -10,5 +10,8 @@ package main

"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"

@@ -38,5 +41,18 @@ "github.com/spf13/pflag"

token := viper.GetString("personal_access_token")
if token == "" {
return errors.New("GITHUB_PERSONAL_ACCESS_TOKEN not set")
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")
}

@@ -100,2 +116,25 @@ // If you're wondering why we're not using viper.GetStringSlice("toolsets"),

}
// 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)

@@ -189,2 +228,10 @@ },

// 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

@@ -212,2 +259,6 @@ httpCmd.Flags().Int("port", 8082, "HTTP server port")

_ = 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"))

@@ -214,0 +265,0 @@ _ = viper.BindPFlag("listen-host", httpCmd.Flags().Lookup("listen-host"))

@@ -1,2 +0,2 @@

FROM node:26-alpine@sha256:3ad34ca6292aec4a91d8ddeb9229e29d9c2f689efd0dd242860889ac71842eba AS ui-build
FROM node:26-alpine@sha256:a2dc166a387cc6ca1e62d0c8e265e49ca985d6e60abc9fe6e6c3d6ce8e63f606 AS ui-build
WORKDIR /app

@@ -10,3 +10,3 @@ COPY ui/package*.json ./ui/

FROM golang:1.25.11-alpine@sha256:8d95af53d0d58e1759ddb4028285d9b1239067e4fbf4f544618cad0f60fbc354 AS build
FROM golang:1.25.11-alpine@sha256:523c3effe300580ed375e43f43b1c9b091b68e935a7c3a92bfcc4e7ed55b18c2 AS build
ARG VERSION="dev"

@@ -28,5 +28,10 @@

# 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 \
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)" \
--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

@@ -33,0 +38,0 @@

@@ -48,3 +48,2 @@ # Feature Flags

- `reviewers`: GitHub usernames or ORG/team-slug team reviewers to request reviews from (string[], optional)
- `show_ui`: Whether to render the MCP App form instead of executing the request immediately. Defaults to true. Set to false to skip the form and execute directly — useful when you have all required values (especially ones the form does not collect, like reviewers) and the user has already confirmed the action. (boolean, optional, conditional — visible when remote_mcp_ui_apps is enabled unless the client explicitly indicates it does not support io.modelcontextprotocol/ui)
- `title`: PR title (string, required)

@@ -73,3 +72,2 @@

- `repo`: Repository name (string, required)
- `show_ui`: Whether to render the MCP App form instead of executing the request immediately. Defaults to true. Set to false to skip the form and execute directly — useful when you have all required values (especially ones the form does not collect, like labels, assignees, milestone, type, issue_fields, or state changes) and the user has already confirmed the action. (boolean, optional, conditional — visible when remote_mcp_ui_apps is enabled unless the client explicitly indicates it does not support io.modelcontextprotocol/ui)
- `state`: New state (string, optional)

@@ -103,2 +101,16 @@ - `state_reason`: Reason for the state change. Ignored unless state is changed. (string, optional)

- **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

@@ -210,2 +222,9 @@ - **Required OAuth Scopes**: `repo`

- **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

@@ -290,2 +309,34 @@ - **Required OAuth Scopes**: `repo`

### `issue_dependencies`
- **issue_dependency_read** - Read issue dependencies
- **Required OAuth Scopes**: `repo`
- `after`: Cursor for pagination. Use the cursor from the previous response. (string, optional)
- `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)
- `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 FEATURE FLAG TOOLS -->

@@ -42,3 +42,2 @@ # Insiders Features

- `reviewers`: GitHub usernames or ORG/team-slug team reviewers to request reviews from (string[], optional)
- `show_ui`: Whether to render the MCP App form instead of executing the request immediately. Defaults to true. Set to false to skip the form and execute directly — useful when you have all required values (especially ones the form does not collect, like reviewers) and the user has already confirmed the action. (boolean, optional, conditional — visible when remote_mcp_ui_apps is enabled unless the client explicitly indicates it does not support io.modelcontextprotocol/ui)
- `title`: PR title (string, required)

@@ -67,3 +66,2 @@

- `repo`: Repository name (string, required)
- `show_ui`: Whether to render the MCP App form instead of executing the request immediately. Defaults to true. Set to false to skip the form and execute directly — useful when you have all required values (especially ones the form does not collect, like labels, assignees, milestone, type, issue_fields, or state changes) and the user has already confirmed the action. (boolean, optional, conditional — visible when remote_mcp_ui_apps is enabled unless the client explicitly indicates it does not support io.modelcontextprotocol/ui)
- `state`: New state (string, optional)

@@ -108,2 +106,34 @@ - `state_reason`: Reason for the state change. Ignored unless state is changed. (string, optional)

### `issue_dependencies`
- **issue_dependency_read** - Read issue dependencies
- **Required OAuth Scopes**: `repo`
- `after`: Cursor for pagination. Use the cursor from the previous response. (string, optional)
- `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)
- `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 -->

@@ -110,0 +140,0 @@

@@ -78,2 +78,4 @@ # Installing GitHub MCP Server in Antigravity

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

@@ -88,3 +90,30 @@ {

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

@@ -91,0 +120,0 @@ "ghcr.io/github/github-mcp-server"

@@ -66,2 +66,12 @@ # Install GitHub MCP Server in Claude Applications

### 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):

@@ -140,2 +150,4 @@ ```bash

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

@@ -150,3 +162,30 @@ {

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

@@ -218,2 +257,4 @@ "ghcr.io/github/github-mcp-server"

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

@@ -228,3 +269,30 @@ {

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

@@ -231,0 +299,0 @@ "ghcr.io/github/github-mcp-server"

@@ -32,4 +32,6 @@ # Install GitHub MCP Server in Cline

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 the configuration below, replacing `YOUR_GITHUB_PAT` with your [GitHub Personal Access Token](https://github.com/settings/tokens).
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

@@ -42,2 +44,25 @@ {

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

@@ -44,0 +69,0 @@ "ghcr.io/github/github-mcp-server"

@@ -48,4 +48,24 @@ # Install GitHub MCP Server in OpenAI Codex

Use this if you prefer a local, self-hosted instance instead of the remote HTTP server, please refer to the [OpenAI documentation for configuration](https://developers.openai.com/codex/mcp).
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

@@ -52,0 +72,0 @@

@@ -98,2 +98,4 @@ # Install GitHub MCP Server in Copilot CLI

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

@@ -108,3 +110,30 @@ {

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

@@ -111,0 +140,0 @@ "ghcr.io/github/github-mcp-server"

@@ -54,2 +54,4 @@ # Install GitHub MCP Server in Cursor

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

@@ -64,3 +66,30 @@ {

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

@@ -67,0 +96,0 @@ "ghcr.io/github/github-mcp-server"

@@ -62,4 +62,6 @@ # Install GitHub MCP Server in Google Gemini CLI

With docker running, you can run the GitHub MCP server in a container:
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

@@ -75,3 +77,31 @@ // ~/.gemini/settings.json

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

@@ -109,2 +139,4 @@ "ghcr.io/github/github-mcp-server"

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

@@ -111,0 +143,0 @@

@@ -64,2 +64,4 @@ # Install GitHub MCP Server in OpenCode

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

@@ -73,2 +75,27 @@ {

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

@@ -75,0 +102,0 @@ "ghcr.io/github/github-mcp-server"

@@ -43,5 +43,25 @@ # Install GitHub MCP Server in Copilot IDEs

1. Create an `.mcp.json` file in your solution or %USERPROFILE% directory.
2. Add this configuration:
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": [

@@ -113,2 +133,4 @@ {

#### 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

@@ -121,2 +143,23 @@ {

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

@@ -171,2 +214,4 @@ "ghcr.io/github/github-mcp-server"

#### 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

@@ -179,2 +224,23 @@ {

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

@@ -230,2 +296,4 @@ "ghcr.io/github/github-mcp-server"

#### 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

@@ -238,2 +306,23 @@ {

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

@@ -240,0 +329,0 @@ "ghcr.io/github/github-mcp-server"

@@ -36,2 +36,4 @@ # Install GitHub MCP Server in Roo Code

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

@@ -44,2 +46,25 @@ {

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

@@ -46,0 +71,0 @@ "ghcr.io/github/github-mcp-server"

@@ -33,2 +33,4 @@ # Install GitHub MCP Server in Windsurf

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

@@ -43,3 +45,30 @@ {

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

@@ -46,0 +75,0 @@ "ghcr.io/github/github-mcp-server"

@@ -32,2 +32,4 @@ # Install GitHub MCP Server in Xcode

> **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

@@ -34,0 +36,0 @@

@@ -44,2 +44,4 @@ # Install GitHub MCP Server in Zed

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

@@ -52,2 +54,25 @@ {

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

@@ -54,0 +79,0 @@ "ghcr.io/github/github-mcp-server"

+1
-1

@@ -22,2 +22,3 @@ module github.com/github/github-mcp-server

github.com/yosida95/uritemplate/v3 v3.0.2
golang.org/x/oauth2 v0.35.0
)

@@ -44,3 +45,2 @@

golang.org/x/net v0.38.0 // indirect
golang.org/x/oauth2 v0.35.0 // indirect
golang.org/x/sys v0.41.0 // indirect

@@ -47,0 +47,0 @@ golang.org/x/text v0.28.0 // indirect

@@ -15,2 +15,3 @@ package ghmcp

"github.com/github/github-mcp-server/internal/oauth"
"github.com/github/github-mcp-server/pkg/errors"

@@ -65,3 +66,6 @@ "github.com/github/github-mcp-server/pkg/github"

// Construct REST client
// 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{

@@ -71,7 +75,18 @@ Transport: http.DefaultTransport,

}
restClient, err := gogithub.NewClient(
gogithub.WithHTTPClient(&http.Client{Transport: restUATransport}),
gogithub.WithAuthToken(cfg.Token),
gogithub.WithEnterpriseURLs(restURL.String(), uploadURL.String()),
)
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 {

@@ -88,3 +103,4 @@ return nil, fmt.Errorf("failed to create REST client: %w", err)

},
Token: cfg.Token,
Token: cfg.Token,
TokenProvider: cfg.TokenProvider,
},

@@ -236,2 +252,14 @@ }

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
}

@@ -241,2 +269,9 @@

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

@@ -264,7 +299,9 @@ ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)

// Fetch token scopes for scope-based tool filtering (PAT tokens only)
// Only classic PATs (ghp_ prefix) return OAuth scopes via X-OAuth-Scopes header.
// Fine-grained PATs and other token types don't support this, so we skip filtering.
// 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
if strings.HasPrefix(cfg.Token, "ghp_") {
switch {
case strings.HasPrefix(cfg.Token, "ghp_"):
fetchedScopes, err := fetchTokenScopesForHost(ctx, cfg.Token, cfg.Host)

@@ -277,6 +314,16 @@ if err != nil {

}
} else {
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{

@@ -298,2 +345,3 @@ Version: cfg.Version,

TokenScopes: tokenScopes,
TokenProvider: tokenProvider,
})

@@ -304,2 +352,8 @@ if err != nil {

// 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 {

@@ -306,0 +360,0 @@ // Once server is initialized, all translations are loaded

@@ -19,2 +19,9 @@ package github

// FeatureFlagIssueDependencies is the feature flag name for the issue dependency
// tools (issue_dependency_read / issue_dependency_write), which read and edit an
// issue's blocked-by / blocking relationships. It is gated so these tools are not
// advertised in the default surface, keeping the fixed tool-schema cost small
// unless explicitly opted in.
const FeatureFlagIssueDependencies = "issue_dependencies"
// AllowedFeatureFlags is the allowlist of feature flags that can be enabled

@@ -31,2 +38,3 @@ // by users via --features CLI flag or X-MCP-Features HTTP header.

FeatureFlagFileBlame,
FeatureFlagIssueDependencies,
}

@@ -42,2 +50,3 @@

FeatureFlagFileBlame,
FeatureFlagIssueDependencies,
}

@@ -44,0 +53,0 @@

@@ -61,5 +61,7 @@ package github

GetReposIssuesByOwnerByRepoByIssueNumber = "GET /repos/{owner}/{repo}/issues/{issue_number}"
GetReposIssuesCommentByOwnerByRepoByCommentID = "GET /repos/{owner}/{repo}/issues/comments/{comment_id}"
GetReposIssuesCommentsByOwnerByRepoByIssueNumber = "GET /repos/{owner}/{repo}/issues/{issue_number}/comments"
PostReposIssuesByOwnerByRepo = "POST /repos/{owner}/{repo}/issues"
PostReposIssuesCommentsByOwnerByRepoByIssueNumber = "POST /repos/{owner}/{repo}/issues/{issue_number}/comments"
PostReposIssuesReactionsByOwnerByRepoByIssueNumber = "POST /repos/{owner}/{repo}/issues/{issue_number}/reactions"
PatchReposIssuesByOwnerByRepoByIssueNumber = "PATCH /repos/{owner}/{repo}/issues/{issue_number}"

@@ -70,2 +72,4 @@ GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber = "GET /repos/{owner}/{repo}/issues/{issue_number}/sub_issues"

PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber = "PATCH /repos/{owner}/{repo}/issues/{issue_number}/sub_issues/priority"
PostReposIssuesCommentsReactionsByOwnerByRepoByCommentID = "POST /repos/{owner}/{repo}/issues/comments/{comment_id}/reactions"
DeleteReposIssuesIssueFieldValueByOwnerByRepoByIssueNumber = "DELETE /repos/{owner}/{repo}/issues/{issue_number}/issue-field-values/{issue_field_id}"

@@ -84,2 +88,3 @@ // Pull request endpoints

PostReposPullsCommentsByOwnerByRepoByPullNumber = "POST /repos/{owner}/{repo}/pulls/{pull_number}/comments"
PostReposPullsCommentsReactionsByOwnerByRepoByCommentID = "POST /repos/{owner}/{repo}/pulls/comments/{comment_id}/reactions"

@@ -86,0 +91,0 @@ // Notifications endpoints

@@ -1201,1 +1201,165 @@ package github

}
// GranularAddIssueReaction adds a reaction to an issue or pull request.
func GranularAddIssueReaction(t translations.TranslationHelperFunc) inventory.ServerTool {
st := NewTool(
ToolsetMetadataIssues,
mcp.Tool{
Name: "add_issue_reaction",
Description: t("TOOL_ADD_ISSUE_REACTION_DESCRIPTION", "Add a reaction to an issue or pull request."),
Annotations: &mcp.ToolAnnotations{
Title: t("TOOL_ADD_ISSUE_REACTION_USER_TITLE", "Add Reaction to Issue or Pull Request"),
ReadOnlyHint: false,
DestructiveHint: jsonschema.Ptr(false),
OpenWorldHint: jsonschema.Ptr(true),
},
InputSchema: &jsonschema.Schema{
Type: "object",
Properties: map[string]*jsonschema.Schema{
"owner": {
Type: "string",
Description: "Repository owner (username or organization)",
},
"repo": {
Type: "string",
Description: "Repository name",
},
"issue_number": {
Type: "number",
Description: "The issue number",
Minimum: jsonschema.Ptr(1.0),
},
"content": {
Type: "string",
Description: "The emoji reaction type",
Enum: []any{"+1", "-1", "laugh", "confused", "heart", "hooray", "rocket", "eyes"},
},
},
Required: []string{"owner", "repo", "issue_number", "content"},
},
},
[]scopes.Scope{scopes.Repo},
func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
owner, err := RequiredParam[string](args, "owner")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
repo, err := RequiredParam[string](args, "repo")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
issueNumber, err := RequiredInt(args, "issue_number")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
content, err := RequiredParam[string](args, "content")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
client, err := deps.GetClient(ctx)
if err != nil {
return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil
}
reaction, resp, err := client.Reactions.CreateIssueReaction(ctx, owner, repo, issueNumber, content)
if err != nil {
return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to add reaction to issue", resp, err), nil, nil
}
defer func() { _ = resp.Body.Close() }()
r, err := json.Marshal(MinimalResponse{
ID: fmt.Sprintf("%d", reaction.GetID()),
URL: fmt.Sprintf("%srepos/%s/%s/issues/%d/reactions/%d", client.BaseURL(), owner, repo, issueNumber, reaction.GetID()),
})
if err != nil {
return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil
}
return utils.NewToolResultText(string(r)), nil, nil
},
)
st.FeatureFlagEnable = FeatureFlagIssuesGranular
return st
}
// GranularAddIssueCommentReaction adds a reaction to an issue or pull request comment.
func GranularAddIssueCommentReaction(t translations.TranslationHelperFunc) inventory.ServerTool {
st := NewTool(
ToolsetMetadataIssues,
mcp.Tool{
Name: "add_issue_comment_reaction",
Description: t("TOOL_ADD_ISSUE_COMMENT_REACTION_DESCRIPTION", "Add a reaction to an issue or pull request comment."),
Annotations: &mcp.ToolAnnotations{
Title: t("TOOL_ADD_ISSUE_COMMENT_REACTION_USER_TITLE", "Add Reaction to Issue or Pull Request Comment"),
ReadOnlyHint: false,
DestructiveHint: jsonschema.Ptr(false),
OpenWorldHint: jsonschema.Ptr(true),
},
InputSchema: &jsonschema.Schema{
Type: "object",
Properties: map[string]*jsonschema.Schema{
"owner": {
Type: "string",
Description: "Repository owner (username or organization)",
},
"repo": {
Type: "string",
Description: "Repository name",
},
"comment_id": {
Type: "number",
Description: "The issue or pull request comment ID",
Minimum: jsonschema.Ptr(1.0),
},
"content": {
Type: "string",
Description: "The emoji reaction type",
Enum: []any{"+1", "-1", "laugh", "confused", "heart", "hooray", "rocket", "eyes"},
},
},
Required: []string{"owner", "repo", "comment_id", "content"},
},
},
[]scopes.Scope{scopes.Repo},
func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
owner, err := RequiredParam[string](args, "owner")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
repo, err := RequiredParam[string](args, "repo")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
commentID, err := RequiredBigInt(args, "comment_id")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
content, err := RequiredParam[string](args, "content")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
client, err := deps.GetClient(ctx)
if err != nil {
return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil
}
reaction, resp, err := client.Reactions.CreateIssueCommentReaction(ctx, owner, repo, commentID, content)
if err != nil {
return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to add reaction to issue comment", resp, err), nil, nil
}
defer func() { _ = resp.Body.Close() }()
r, err := json.Marshal(MinimalResponse{
ID: fmt.Sprintf("%d", reaction.GetID()),
URL: fmt.Sprintf("%srepos/%s/%s/issues/comments/%d/reactions/%d", client.BaseURL(), owner, repo, commentID, reaction.GetID()),
})
if err != nil {
return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil
}
return utils.NewToolResultText(string(r)), nil, nil
},
)
st.FeatureFlagEnable = FeatureFlagIssuesGranular
return st
}

@@ -760,1 +760,83 @@ package github

}
// GranularAddPullRequestReviewCommentReaction adds a reaction to a pull request review comment.
func GranularAddPullRequestReviewCommentReaction(t translations.TranslationHelperFunc) inventory.ServerTool {
st := NewTool(
ToolsetMetadataPullRequests,
mcp.Tool{
Name: "add_pull_request_review_comment_reaction",
Description: t("TOOL_ADD_PULL_REQUEST_REVIEW_COMMENT_REACTION_DESCRIPTION", "Add a reaction to a pull request review comment."),
Annotations: &mcp.ToolAnnotations{
Title: t("TOOL_ADD_PULL_REQUEST_REVIEW_COMMENT_REACTION_USER_TITLE", "Add Pull Request Review Comment Reaction"),
ReadOnlyHint: false,
DestructiveHint: jsonschema.Ptr(false),
OpenWorldHint: jsonschema.Ptr(true),
},
InputSchema: &jsonschema.Schema{
Type: "object",
Properties: map[string]*jsonschema.Schema{
"owner": {
Type: "string",
Description: "Repository owner (username or organization)",
},
"repo": {
Type: "string",
Description: "Repository name",
},
"comment_id": {
Type: "number",
Description: "The numeric pull request review comment ID. Use the number from a #discussion_r... anchor, not the GraphQL thread node ID (PRRT_...).",
Minimum: jsonschema.Ptr(1.0),
},
"content": {
Type: "string",
Description: "The emoji reaction type",
Enum: []any{"+1", "-1", "laugh", "confused", "heart", "hooray", "rocket", "eyes"},
},
},
Required: []string{"owner", "repo", "comment_id", "content"},
},
},
[]scopes.Scope{scopes.Repo},
func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
owner, err := RequiredParam[string](args, "owner")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
repo, err := RequiredParam[string](args, "repo")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
commentID, err := RequiredBigInt(args, "comment_id")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
content, err := RequiredParam[string](args, "content")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
client, err := deps.GetClient(ctx)
if err != nil {
return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil
}
reaction, resp, err := client.Reactions.CreatePullRequestCommentReaction(ctx, owner, repo, commentID, content)
if err != nil {
return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to add reaction to pull request review comment", resp, err), nil, nil
}
defer func() { _ = resp.Body.Close() }()
r, err := json.Marshal(MinimalResponse{
ID: fmt.Sprintf("%d", reaction.GetID()),
URL: fmt.Sprintf("%srepos/%s/%s/pulls/comments/%d/reactions/%d", client.BaseURL(), owner, repo, commentID, reaction.GetID()),
})
if err != nil {
return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil
}
return utils.NewToolResultText(string(r)), nil, nil
},
)
st.FeatureFlagEnable = FeatureFlagPullRequestsGranular
return st
}

@@ -71,2 +71,7 @@ package github

// TokenProvider, when non-nil, supplies the GitHub token for each API
// request instead of the static Token. It backs OAuth login, where the
// token is obtained lazily on first use and refreshed thereafter.
TokenProvider func() string
// Additional server options to apply

@@ -73,0 +78,0 @@ ServerOptions []MCPServerOption

@@ -219,2 +219,4 @@ package github

SubIssueWrite(t),
IssueDependencyRead(t),
IssueDependencyWrite(t),

@@ -318,2 +320,4 @@ // User tools

GranularSetIssueFields(t),
GranularAddIssueReaction(t),
GranularAddIssueCommentReaction(t),

@@ -332,2 +336,3 @@ // Granular pull request tools (feature-flagged, replace consolidated update_pull_request/pull_request_review_write)

GranularUnresolveReviewThread(t),
GranularAddPullRequestReviewCommentReaction(t),
})

@@ -334,0 +339,0 @@ }

@@ -36,1 +36,42 @@ package github

}
// uiSubmitted reports whether the call is itself an MCP App form submission.
// The form re-invokes its tool with _ui_submitted=true; such calls must execute
// rather than re-render the form.
func uiSubmitted(args map[string]any) bool {
submitted, _ := OptionalParam[bool](args, "_ui_submitted")
return submitted
}
// hasNonFormParams reports whether the call carries any parameter the tool's MCP
// App form cannot represent (anything outside formParams). Such calls must
// bypass the form and execute directly so the supplied values aren't silently
// dropped. formParams is the set of parameters the form collects and re-sends
// on submit.
func hasNonFormParams(args map[string]any, formParams map[string]struct{}) bool {
for key, value := range args {
if value == nil {
continue
}
if _, ok := formParams[key]; !ok {
return true
}
}
return false
}
// shouldDeferToForm is the single source of truth for the show/defer decision
// shared by the form-backed write tools (create_pull_request,
// update_pull_request, issue_write). It reports whether a call should be handed
// off to its MCP App form instead of executing now: defer only when MCP Apps
// are enabled, the client can render UI, the call is not itself a form
// submission, and every supplied parameter can be represented by the form
// (formParams is the tool's form-parameter allowlist). When it returns false
// the handler executes directly; the host may still render the tool's view,
// which renders the result rather than an input form.
func shouldDeferToForm(ctx context.Context, deps ToolDependencies, req *mcp.CallToolRequest, args map[string]any, formParams map[string]struct{}) bool {
return deps.IsFeatureEnabled(ctx, MCPAppsFeatureFlag) &&
clientSupportsUI(ctx, req) &&
!uiSubmitted(args) &&
!hasNonFormParams(args, formParams)
}

@@ -6,3 +6,6 @@ package github

"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strconv"
"testing"

@@ -21,2 +24,74 @@ "time"

// recorderTransport routes HTTP requests through an in-process handler, mirroring
// internal/githubv4mock's own transport. We need it because githubv4mock keys its
// matchers by query string, so it cannot model a multi-page labels query: every
// page issues the identical query and differs only by the $cursor variable. This
// transport lets a single handler answer each page dynamically.
type recorderTransport struct{ handler http.Handler }
func (rt recorderTransport) RoundTrip(req *http.Request) (*http.Response, error) {
rec := httptest.NewRecorder()
rt.handler.ServeHTTP(rec, req)
return rec.Result(), nil
}
// alwaysHasNextPageLabelsClient returns a GraphQL client whose labels query always
// reports another page, advancing the cursor on each call. It exercises uiGetLabels'
// page cap: the loop fetches one label per page until it stops at uiGetMaxPages with
// has_more=true. totalCount is reported as a large server-side count so the test can
// confirm it stays the full repo count even when results are truncated.
func alwaysHasNextPageLabelsClient(t *testing.T) *http.Client {
t.Helper()
var calls int
mux := http.NewServeMux()
mux.HandleFunc("/graphql", func(w http.ResponseWriter, _ *http.Request) {
calls++
resp := map[string]any{
"data": map[string]any{
"repository": map[string]any{
"labels": map[string]any{
"nodes": []any{
map[string]any{
"id": fmt.Sprintf("label-%d", calls),
"name": fmt.Sprintf("label-%d", calls),
"color": "ededed",
"description": "",
},
},
"totalCount": 9999,
"pageInfo": map[string]any{
"hasNextPage": true,
"endCursor": fmt.Sprintf("cursor-%d", calls),
},
},
},
},
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
})
return &http.Client{Transport: recorderTransport{handler: mux}}
}
// alwaysNextPageHandler returns a REST handler that always advertises another page
// via the Link header, regardless of the page requested. It drives a pagination loop
// purely off the page cap so tests can assert ui_get stops at uiGetMaxPages and sets
// has_more=true. The same body is returned for every page, so the number of items
// collected equals the number of pages fetched.
func alwaysNextPageHandler(t *testing.T, body any) http.HandlerFunc {
t.Helper()
return func(w http.ResponseWriter, r *http.Request) {
page := 1
if p := r.URL.Query().Get("page"); p != "" {
if parsed, err := strconv.Atoi(p); err == nil {
page = parsed
}
}
w.Header().Set("Link", fmt.Sprintf(`<https://api.github.com/next?page=%d>; rel="next"`, page+1))
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(body)
}
}
func Test_UIGet(t *testing.T) {

@@ -100,2 +175,3 @@ // Verify tool definition

assert.Contains(t, response, "totalCount")
assert.Equal(t, false, response["has_more"], "results within the page cap should not be truncated")
},

@@ -119,2 +195,3 @@ },

assert.Contains(t, response, "totalCount")
assert.Equal(t, false, response["has_more"], "results within the page cap should not be truncated")
},

@@ -235,2 +312,3 @@ },

assert.Equal(t, float64(1), response["totalCount"])
assert.Equal(t, false, response["has_more"], "results within the page cap should not be truncated")
},

@@ -308,5 +386,69 @@ },

assert.Equal(t, float64(2), response["totalCount"])
assert.Equal(t, false, response["has_more"], "results within the page cap should not be truncated")
},
},
{
name: "branches pagination stops at the page cap",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
"GET /repos/owner/repo/branches": alwaysNextPageHandler(t, []*github.Branch{{Name: github.Ptr("feature")}}),
}),
requestArgs: map[string]any{
"method": "branches",
"owner": "owner",
"repo": "repo",
},
expectError: false,
validateResult: func(t *testing.T, responseText string) {
var response map[string]any
require.NoError(t, json.Unmarshal([]byte(responseText), &response))
branches, ok := response["branches"].([]any)
require.True(t, ok, "branches should be a list")
assert.Len(t, branches, uiGetMaxPages, "loop should stop at the page cap")
assert.Equal(t, float64(uiGetMaxPages), response["totalCount"], "totalCount should be the bounded count")
assert.Equal(t, true, response["has_more"], "truncated results should set has_more")
},
},
{
name: "labels pagination stops at the page cap",
mockedGQLClient: alwaysHasNextPageLabelsClient(t),
requestArgs: map[string]any{
"method": "labels",
"owner": "owner",
"repo": "repo",
},
expectError: false,
validateResult: func(t *testing.T, responseText string) {
var response map[string]any
require.NoError(t, json.Unmarshal([]byte(responseText), &response))
labels, ok := response["labels"].([]any)
require.True(t, ok, "labels should be a list")
assert.Len(t, labels, uiGetMaxPages, "loop should stop at the page cap")
assert.Equal(t, true, response["has_more"], "truncated results should set has_more")
// totalCount stays the server-reported full count, so it can exceed
// the number of labels returned once results are truncated.
assert.Equal(t, float64(9999), response["totalCount"])
},
},
{
name: "reviewers pagination stops at the page cap",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
"GET /repos/owner/repo/collaborators": alwaysNextPageHandler(t, []*github.User{{Login: github.Ptr("octocat")}}),
"GET /repos/owner/repo/teams": mockResponse(t, http.StatusOK, mockReviewerTeams),
}),
requestArgs: map[string]any{
"method": "reviewers",
"owner": "owner",
"repo": "repo",
},
expectError: false,
validateResult: func(t *testing.T, responseText string) {
var response map[string]any
require.NoError(t, json.Unmarshal([]byte(responseText), &response))
users, ok := response["users"].([]any)
require.True(t, ok, "users should be a list")
assert.Len(t, users, uiGetMaxPages, "collaborators loop should stop at the page cap")
assert.Equal(t, true, response["has_more"], "truncating either loop should set has_more")
},
},
{
name: "missing method parameter",

@@ -313,0 +455,0 @@ mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}),

@@ -23,2 +23,11 @@ package github

// uiGetMaxPages bounds how many pages each ui_get pagination loop will fetch.
// ui_get backs a synchronous UI picker (dropdowns for labels/assignees/etc. in
// the MCP App issue/PR write surfaces), so responsiveness matters more than
// completeness. At PerPage 100 this caps a call at ~1000 items and a bounded
// number of API round-trips, keeping latency predictable on very large
// repos/orgs. Results past the cap are truncated and surfaced via a "has_more"
// flag, which is acceptable because the picker pairs truncation with typeahead.
const uiGetMaxPages = 10
// UIGet creates a tool to fetch UI data for MCP Apps.

@@ -135,3 +144,4 @@ func UIGet(t translations.TranslationHelperFunc) inventory.ServerTool {

var totalCount int
for {
hasMore := false
for page := 1; ; page++ {
if err := client.Query(ctx, &query, vars); err != nil {

@@ -152,2 +162,6 @@ return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to list labels", err), nil, nil

}
if page >= uiGetMaxPages {
hasMore = true
break
}
vars["cursor"] = githubv4.NewString(query.Repository.Labels.PageInfo.EndCursor)

@@ -159,2 +173,3 @@ }

"totalCount": totalCount,
"has_more": hasMore,
}

@@ -183,4 +198,5 @@

var allAssignees []*github.User
hasMore := false
for {
for page := 1; ; page++ {
assignees, resp, err := client.Issues.ListAssignees(ctx, owner, repo, opts)

@@ -197,2 +213,6 @@ if err != nil {

}
if page >= uiGetMaxPages {
hasMore = true
break
}
opts.Page = resp.NextPage

@@ -212,2 +232,3 @@ }

"totalCount": len(result),
"has_more": hasMore,
})

@@ -238,3 +259,4 @@ if err != nil {

var allMilestones []*github.Milestone
for {
hasMore := false
for page := 1; ; page++ {
milestones, resp, err := client.Issues.ListMilestones(ctx, owner, repo, opts)

@@ -251,2 +273,6 @@ if err != nil {

}
if page >= uiGetMaxPages {
hasMore = true
break
}
opts.Page = resp.NextPage

@@ -274,2 +300,3 @@ }

"totalCount": len(result),
"has_more": hasMore,
})

@@ -327,3 +354,4 @@ if err != nil {

var allBranches []*github.Branch
for {
hasMore := false
for page := 1; ; page++ {
branches, resp, err := client.Repositories.ListBranches(ctx, owner, repo, opts)

@@ -340,2 +368,6 @@ if err != nil {

}
if page >= uiGetMaxPages {
hasMore = true
break
}
opts.Page = resp.NextPage

@@ -352,2 +384,3 @@ }

"totalCount": len(minimalBranches),
"has_more": hasMore,
})

@@ -462,3 +495,4 @@ if err != nil {

var allCollaborators []*github.User
for {
hasMore := false
for page := 1; ; page++ {
collaborators, resp, err := client.Repositories.ListCollaborators(ctx, owner, repo, collaboratorOpts)

@@ -475,2 +509,6 @@ if err != nil {

}
if page >= uiGetMaxPages {
hasMore = true
break
}
collaboratorOpts.Page = resp.NextPage

@@ -481,3 +519,3 @@ }

var allTeams []*github.Team
for {
for page := 1; ; page++ {
teams, resp, err := client.Repositories.ListTeams(ctx, owner, repo, teamOpts)

@@ -494,2 +532,6 @@ if err != nil {

}
if page >= uiGetMaxPages {
hasMore = true
break
}
teamOpts.Page = resp.NextPage

@@ -523,2 +565,3 @@ }

"totalCount": len(users) + len(teams),
"has_more": hasMore,
})

@@ -525,0 +568,0 @@ if err != nil {

@@ -22,3 +22,9 @@ // Package oauth provides OAuth 2.0 Protected Resource Metadata (RFC 9728) support

// SupportedScopes lists all OAuth scopes that may be required by MCP tools.
// SupportedScopes lists every OAuth scope that an MCP tool may require. It is the
// source of truth in two places: HTTP mode advertises it as scopes_supported in
// the protected-resource metadata, and stdio OAuth login requests it by default
// and then filters the exposed tools to the granted scopes. A tool whose required
// scope is absent here is therefore hidden under default OAuth even though a PAT
// carrying that scope would expose it, so keep this list in sync with tool scope
// requirements when scopes change.
var SupportedScopes = []string{

@@ -25,0 +31,0 @@ "repo",

@@ -14,2 +14,8 @@ package transport

Token string
// TokenProvider, when non-nil, supplies the bearer token for each request
// and takes precedence over Token. It backs OAuth, where the token is
// obtained after the client is built and is refreshed over the session's
// lifetime. It may return an empty string before authorization completes.
TokenProvider func() string
}

@@ -19,3 +25,11 @@

req = req.Clone(req.Context())
req.Header.Set(headers.AuthorizationHeader, "Bearer "+t.Token)
token := t.Token
if t.TokenProvider != nil {
token = t.TokenProvider()
}
// Before OAuth authorization completes the token is empty; send an
// unauthenticated request rather than an empty "Bearer " header.
if token != "" {
req.Header.Set(headers.AuthorizationHeader, "Bearer "+token)
}

@@ -22,0 +36,0 @@ // Check for GraphQL-Features in context and add header if present

@@ -10,4 +10,2 @@ package inventory

"strings"
"github.com/google/jsonschema-go/jsonschema"
)

@@ -412,93 +410,2 @@

// uiOnlySchemaProperties lists input-schema property names that should only
// be visible to clients that advertise MCP Apps UI support. They live on the
// static schema (so toolsnaps and the feature-flag / insiders docs document
// the full UI-capable surface; the main README renders the stripped
// non-UI schema) and are stripped per-request when the same gate that hides
// _meta.ui is true.
var uiOnlySchemaProperties = []string{
"show_ui", // explicit "render the MCP App form" toggle on form-backed write tools
}
// ConditionalSchemaPropertyDescriptions returns a map of schema property name
// to a human-readable description of the condition under which the property
// is visible to clients. The doc generator uses this to annotate conditional
// parameters so readers can see at a glance which fields are not always
// available. This is the single source of truth for the conditional-property
// surface — entries here must correspond to a strip rule in
// ToolsForRegistration.
func ConditionalSchemaPropertyDescriptions() map[string]string {
const uiOnlyCondition = "visible when remote_mcp_ui_apps is enabled unless the client explicitly indicates it does not support io.modelcontextprotocol/ui"
out := make(map[string]string, len(uiOnlySchemaProperties))
for _, name := range uiOnlySchemaProperties {
out[name] = uiOnlyCondition
}
return out
}
// stripUIOnlySchemaProperties removes UI-capability-gated input-schema
// properties (currently just "show_ui") from each tool's static schema.
// Tools whose InputSchema is not a *jsonschema.Schema (e.g. json.RawMessage)
// are passed through untouched — no such tool currently declares a gated
// property, and inferring intent from an opaque schema is not safe.
// Tools without any gated property are returned as-is so we only allocate
// when a change is actually made (mirrors the stripMetaKeys pattern).
func stripUIOnlySchemaProperties(tools []ServerTool) []ServerTool {
result := make([]ServerTool, 0, len(tools))
for _, tool := range tools {
if stripped := stripSchemaProperties(tool, uiOnlySchemaProperties); stripped != nil {
result = append(result, *stripped)
} else {
result = append(result, tool)
}
}
return result
}
// stripSchemaProperties removes the named keys from tool.Tool.InputSchema's
// Properties map (and Required list, if present) and returns a modified copy.
// Returns nil when the schema is not a *jsonschema.Schema or no listed key
// is present, signalling no change.
func stripSchemaProperties(tool ServerTool, keys []string) *ServerTool {
if tool.Tool.InputSchema == nil || len(keys) == 0 {
return nil
}
schema, ok := tool.Tool.InputSchema.(*jsonschema.Schema)
if !ok || schema == nil || len(schema.Properties) == 0 {
return nil
}
hasKey := false
for _, key := range keys {
if _, exists := schema.Properties[key]; exists {
hasKey = true
break
}
}
if !hasKey {
return nil
}
toolCopy := tool
schemaCopy := *schema
newProps := make(map[string]*jsonschema.Schema, len(schema.Properties))
for k, v := range schema.Properties {
if !slices.Contains(keys, k) {
newProps[k] = v
}
}
schemaCopy.Properties = newProps
if len(schemaCopy.Required) > 0 {
newRequired := make([]string, 0, len(schemaCopy.Required))
for _, r := range schemaCopy.Required {
if !slices.Contains(keys, r) {
newRequired = append(newRequired, r)
}
}
schemaCopy.Required = newRequired
}
toolCopy.Tool.InputSchema = &schemaCopy
return &toolCopy
}
// stripMetaKeys removes the specified Meta keys from a single tool.

@@ -505,0 +412,0 @@ // Returns a modified copy if changes were made, nil otherwise.

@@ -172,5 +172,4 @@ package inventory

// ToolsForRegistration returns AvailableTools(ctx) post-processed exactly as
// RegisterTools would expose them: with MCP Apps UI metadata stripped and
// UI-capability-gated input-schema properties (e.g. show_ui) removed when
// the client cannot consume them. Useful for documentation generators and
// RegisterTools would expose them: with MCP Apps UI metadata stripped when
// the client cannot consume it. Useful for documentation generators and
// diagnostics that need the same view of the tool surface the server would

@@ -191,3 +190,2 @@ // register.

tools = stripMCPAppsMetadata(tools)
tools = stripUIOnlySchemaProperties(tools)
}

@@ -213,4 +211,3 @@ return tools

//
// MCP Apps UI metadata (`_meta.ui`) and UI-capability-gated input-schema
// properties (e.g. `show_ui`) are stripped from the registered tools when
// MCP Apps UI metadata (`_meta.ui`) is stripped from the registered tools when
// either the MCP Apps feature flag is not enabled for this request, or the

@@ -217,0 +214,0 @@ // client did not advertise the io.modelcontextprotocol/ui extension. The

@@ -21,9 +21,21 @@ {

"type": "named",
"name": "-p",
"description": "Publish the OAuth callback port to loopback so the in-container login callback is reachable",
"value": "127.0.0.1:8085:8085"
},
{
"type": "named",
"name": "-e",
"description": "Set an environment variable in the runtime",
"description": "Fixed OAuth callback port, matching the published port above",
"value": "GITHUB_OAUTH_CALLBACK_PORT=8085"
},
{
"type": "named",
"name": "-e",
"description": "Optional GitHub Personal Access Token. Omit to log in with OAuth on first use.",
"value": "GITHUB_PERSONAL_ACCESS_TOKEN={token}",
"isRequired": true,
"isRequired": false,
"variables": {
"token": {
"isRequired": true,
"isRequired": false,
"isSecret": true,

@@ -30,0 +42,0 @@ "format": "string"

@@ -27,2 +27,3 @@ import { StrictMode, useState, useCallback, useEffect, useMemo, useRef } from "react";

import { useMcpApp } from "../../hooks/useMcpApp";
import { completedToolResult } from "../../lib/toolResult";
import { MarkdownEditor } from "../../components/MarkdownEditor";

@@ -439,6 +440,17 @@

const { app, error: appError, toolInput, callTool, hostContext, setModelContext, openLink } = useMcpApp({
const { app, error: appError, toolInput, toolResult, callTool, hostContext, setModelContext, openLink } = useMcpApp({
appName: "github-mcp-server-issue-write",
});
// When the server created/updated the issue up-front instead of deferring to
// this form (e.g. the agent passed show_ui=false or parameters the form can't
// represent, such as labels/assignees/issue_fields), the host still renders
// this View and delivers the result via tool-result. Render that completed
// result as success so we never show a create/update form for an issue that
// is already done. The deferral sentinel (awaiting_user_submission) returns
// null here, keeping the form for the normal deferred flow.
// See github/copilot-mcp-core#1864.
const resultIssue = useMemo(() => completedToolResult<IssueResult>(toolResult), [toolResult]);
const shownIssue = successIssue ?? resultIssue;
// Get method and issue_number from toolInput

@@ -1215,6 +1227,6 @@ const method = (toolInput?.method as string) || "create";

if (successIssue) {
if (shownIssue) {
return (
<SuccessView
issue={successIssue}
issue={shownIssue}
owner={owner}

@@ -1221,0 +1233,0 @@ repo={repo}

@@ -28,2 +28,3 @@ import { StrictMode, useState, useCallback, useEffect, useMemo } from "react";

import { useMcpApp } from "../../hooks/useMcpApp";
import { completedToolResult } from "../../lib/toolResult";
import { MarkdownEditor } from "../../components/MarkdownEditor";

@@ -271,3 +272,3 @@

const { app, error: appError, toolInput, callTool, hostContext, setModelContext, openLink } = useMcpApp({
const { app, error: appError, toolInput, toolResult, callTool, hostContext, setModelContext, openLink } = useMcpApp({
appName: "github-mcp-server-edit-pull-request",

@@ -280,2 +281,11 @@ });

// When the server updated the PR up-front instead of deferring to this form,
// the host still renders this View and delivers the updated PR via
// tool-result. Render that completed result as success so we never show an
// edit form for changes already applied. The deferral sentinel
// (awaiting_user_submission) returns null here, keeping the form for the
// normal deferred flow. See github/copilot-mcp-core#1864.
const resultPR = useMemo(() => completedToolResult<PRResult>(toolResult), [toolResult]);
const shownPR = successPR ?? resultPR;
useEffect(() => {

@@ -501,6 +511,6 @@ setTitle("");

if (successPR) {
if (shownPR) {
return (
<AppProvider hostContext={hostContext}>
<SuccessView pr={successPR} owner={owner} repo={repo} submittedTitle={submittedTitle} openLink={openLink} />
<SuccessView pr={shownPR} owner={owner} repo={repo} submittedTitle={submittedTitle} openLink={openLink} />
</AppProvider>

@@ -507,0 +517,0 @@ );

@@ -30,2 +30,3 @@ import { StrictMode, useState, useCallback, useEffect, useMemo } from "react";

import { useMcpApp } from "../../hooks/useMcpApp";
import { completedToolResult } from "../../lib/toolResult";
import { MarkdownEditor } from "../../components/MarkdownEditor";

@@ -193,3 +194,3 @@

const { app, error: appError, toolInput, callTool, hostContext, setModelContext, openLink } = useMcpApp({
const { app, error: appError, toolInput, toolResult, callTool, hostContext, setModelContext, openLink } = useMcpApp({
appName: "github-mcp-server-create-pull-request",

@@ -202,2 +203,12 @@ });

// When the server executed up-front instead of deferring to this form (e.g.
// the agent passed show_ui=false or parameters the form can't represent), the
// host still renders this View and delivers the created PR via tool-result.
// Treat that completed result as a success so we never show a "Create pull
// request" form for a PR that already exists. The deferral sentinel
// (awaiting_user_submission) returns null here, keeping the form for the
// normal deferred flow. See github/copilot-mcp-core#1864.
const resultPR = useMemo(() => completedToolResult<PRResult>(toolResult), [toolResult]);
const shownPR = successPR ?? resultPR;
// Reset all transient form/result state when toolInput changes (new invocation).

@@ -446,6 +457,6 @@ // Without this, the SuccessView from a previous submit stays visible and stale

if (successPR) {
if (shownPR) {
return (
<AppProvider hostContext={hostContext}>
<SuccessView pr={successPR} owner={owner} repo={repo} submittedTitle={submittedTitle} openLink={openLink} />
<SuccessView pr={shownPR} owner={owner} repo={repo} submittedTitle={submittedTitle} openLink={openLink} />
</AppProvider>

@@ -452,0 +463,0 @@ );

@@ -72,2 +72,9 @@ import { useApp as useExtApp } from "@modelcontextprotocol/ext-apps/react";

setToolInput(args);
// A tool-input notification marks a new invocation, and the spec
// guarantees it is delivered before that invocation's tool-result.
// Drop any prior result so a completed result from a previous
// invocation can't leak into the new render (e.g. a stale success card
// showing over a fresh, still-deferred form). The current invocation's
// result, if any, arrives next via ontoolresult.
setToolResult(null);
onToolInput?.(args);

@@ -74,0 +81,0 @@ };

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is too big to display

Sorry, the diff of this file is too big to display

Sorry, the diff of this file is too big to display

Sorry, the diff of this file is too big to display

Sorry, the diff of this file is too big to display

Sorry, the diff of this file is too big to display

Sorry, the diff of this file is too big to display

Sorry, the diff of this file is too big to display