From 6380a5b22322cca4c6db22c00663fef22a61d072 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Mon, 13 Jul 2026 23:14:47 +0200 Subject: [PATCH 01/35] build(deps): bump go-sdk to 1.7.0-pre.2 and migrate OAuth to multi-round-trip elicitation The go-sdk 1.7.0-pre.2 bump defaults to MCP protocol 2026-07-28, which per SEP-2322 forbids the server from initiating JSON-RPC requests (including `elicitation/create`) while serving a request. The OAuth login flow presents the authorization prompt via `ServerSession.Elicit`, so on 2026-07-28 sessions it now errors ("cannot be sent while serving a request ... return an InputRequests map instead"), which broke TestSessionPrompterPromptActions and would break real 2026-07-28 clients (stdio included, since server/discover is transport-agnostic). Migrate the OAuth middleware to multi-round-trip requests (MRTR) while keeping pre-2026-07-28 clients unchanged: - Legacy clients (< 2026-07-28) keep presenting the prompt via server-initiated elicitation exactly as before. - Modern clients (>= 2026-07-28) receive the authorization prompt as an `InputRequests` elicitation returned from the tool call; the client fulfills it and retries, and the middleware then awaits the token and proceeds. This keeps the authorization URL out of the model context. oauth.Manager gains AwaitToken (resume half of MRTR) and Cancel (tear down on decline). Tests cover the accept/decline/no-capability MRTR paths and assert that server-initiated elicitation is reported undeliverable on 2026-07-28. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 18e70efa-1b2d-4290-ba51-b82998db4ff8 --- go.mod | 2 +- go.sum | 4 +- internal/ghmcp/oauth.go | 155 ++++++++++++++++++++++--- internal/ghmcp/oauth_test.go | 197 +++++++++++++++++++++++++------- internal/oauth/manager.go | 80 ++++++++++--- third-party-licenses.darwin.md | 4 +- third-party-licenses.linux.md | 4 +- third-party-licenses.windows.md | 4 +- 8 files changed, 374 insertions(+), 76 deletions(-) diff --git a/go.mod b/go.mod index 358a271a7a..b11455139d 100644 --- a/go.mod +++ b/go.mod @@ -10,7 +10,7 @@ require ( github.com/josephburnett/jd/v2 v2.5.0 github.com/lithammer/fuzzysearch v1.1.8 github.com/microcosm-cc/bluemonday v1.0.27 - github.com/modelcontextprotocol/go-sdk v1.7.0-pre.1 + github.com/modelcontextprotocol/go-sdk v1.7.0-pre.2 github.com/muesli/cache2go v0.0.0-20221011235721-518229cd8021 github.com/shurcooL/githubv4 v0.0.0-20240727222349-48295856cce7 github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466 diff --git a/go.sum b/go.sum index f3f23b549a..64e5b47839 100644 --- a/go.sum +++ b/go.sum @@ -39,8 +39,8 @@ github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8 github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= -github.com/modelcontextprotocol/go-sdk v1.7.0-pre.1 h1:GlMIJyMHFX76bBSQuBCLXZ7pB9cGh4VBS6O5wGd0tgI= -github.com/modelcontextprotocol/go-sdk v1.7.0-pre.1/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts= +github.com/modelcontextprotocol/go-sdk v1.7.0-pre.2 h1:3JwUps1pdSpXYndBMGO9SMca6CSkP9AKnOaKAkSSGHc= +github.com/modelcontextprotocol/go-sdk v1.7.0-pre.2/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts= github.com/muesli/cache2go v0.0.0-20221011235721-518229cd8021 h1:31Y+Yu373ymebRdJN1cWLLooHH8xAr0MhKTEJGV/87g= github.com/muesli/cache2go v0.0.0-20221011235721-518229cd8021/go.mod h1:WERUkUryfUWlrHnFSO/BEUZ+7Ns8aZy7iVOGewxKzcc= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= diff --git a/internal/ghmcp/oauth.go b/internal/ghmcp/oauth.go index abc6d3d11c..130ada1655 100644 --- a/internal/ghmcp/oauth.go +++ b/internal/ghmcp/oauth.go @@ -91,17 +91,47 @@ func (p *sessionPrompter) PromptForm(ctx context.Context, prompt oauth.Prompt) e type oauthAuthenticator interface { HasToken() bool Authenticate(ctx context.Context, prompter oauth.Prompter) (*oauth.Outcome, error) + AwaitToken(ctx context.Context) (*oauth.Outcome, error) + Cancel() +} + +// oauthElicitID is the stable key for the authorization elicitation in the +// multi-round-trip flow. The client echoes it back in InputResponses when it +// retries the tool call, so the middleware can recognize the user's response. +const oauthElicitID = "github_authorization" + +// protocolVersionNoServerElicitation is the first MCP protocol version that +// forbids server-initiated JSON-RPC requests (SEP-2322): from this version on +// the server may not send elicitation/create while serving a request and must +// instead return an InputRequests map from the tool call (multi round-trip +// requests). It mirrors the go-sdk's internal constant of the same value, which +// the SDK does not export. +const protocolVersionNoServerElicitation = "2026-07-28" + +// serverMayInitiateElicitation reports whether the server is permitted to send +// elicitation requests to the client itself, which the spec allows only before +// protocol version 2026-07-28. A nil or un-negotiated session (only reached in +// unit tests; a real tools/call is always initialized) is treated as legacy. +func serverMayInitiateElicitation(ss *mcp.ServerSession) bool { + if ss == nil { + return true + } + params := ss.InitializeParams() + return params == nil || params.ProtocolVersion < protocolVersionNoServerElicitation } // 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. +// elicitation capabilities and protocol version 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. +// authorization flow runs, presenting its prompt over whichever channel the +// negotiated protocol allows: on protocol versions before 2026-07-28 the server +// elicits directly; from 2026-07-28 on, where server-initiated requests are +// forbidden (SEP-2322), it uses multi-round-trip elicitation returned from the +// tool call. Either way the last-resort channel returns the instruction as a +// tool result and asks the user 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) { @@ -114,18 +144,115 @@ func createOAuthMiddleware(mgr oauthAuthenticator, logger *slog.Logger) func(nex 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 + if serverMayInitiateElicitation(callReq.Session) { + return authorizeViaServerElicitation(ctx, mgr, next, method, request, callReq, logger) } - return next(ctx, method, request) + return authorizeViaMultiRoundTrip(ctx, mgr, next, method, request, callReq, logger) + } + } +} + +// authorizeViaServerElicitation drives authorization on legacy protocol versions +// (before 2026-07-28), where the server may present the prompt itself via +// server-initiated elicitation. It blocks until the token arrives, then proceeds. +func authorizeViaServerElicitation(ctx context.Context, mgr oauthAuthenticator, next mcp.MethodHandler, method string, request mcp.Request, callReq *mcp.CallToolRequest, logger *slog.Logger) (mcp.Result, error) { + 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) +} + +// authorizeViaMultiRoundTrip drives authorization on protocol version 2026-07-28 +// and later, where server-initiated requests are forbidden (SEP-2322). The first +// tool call starts the flow and returns the authorization prompt as an +// elicitation input request; the client presents it and retries the call with +// the user's response, at which point we wait for the token and proceed. +func authorizeViaMultiRoundTrip(ctx context.Context, mgr oauthAuthenticator, next mcp.MethodHandler, method string, request mcp.Request, callReq *mcp.CallToolRequest, logger *slog.Logger) (mcp.Result, error) { + // Retry: the client fulfilled the authorization elicitation and re-sent the + // call with the user's response. + if resp, ok := callReq.Params.InputResponses[oauthElicitID]; ok { + res, _ := resp.(*mcp.ElicitResult) + if res == nil || res.Action != "accept" { + // The user declined or dismissed the prompt; tear the flow down so it + // does not linger, and let them retry when they are ready. + mgr.Cancel() + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: "GitHub authorization was declined. Retry when you're ready to authorize."}}, + }, nil } + outcome, err := mgr.AwaitToken(ctx) + if err != nil { + return nil, fmt.Errorf("github authorization failed: %w", err) + } + if outcome != nil && outcome.UserAction != nil { + // The user acknowledged the prompt but has not finished authorizing; + // surface the instructions so they can complete it and retry. + 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) + } + + // First attempt: start the flow. A nil prompter keeps the manager from + // initiating any elicitation itself (forbidden on this protocol); it opens a + // server-side browser when possible, otherwise returns the authorization + // instructions for us to present via multi-round-trip elicitation. + outcome, err := mgr.Authenticate(ctx, nil) + if err != nil { + return nil, fmt.Errorf("github authorization failed: %w", err) + } + if outcome == nil || outcome.UserAction == nil { + // Already authorized (e.g. the server opened a browser and the flow + // completed); proceed. + return next(ctx, method, request) + } + + elicit := authorizationElicitParams(outcome.UserAction, &sessionPrompter{session: callReq.Session}) + if elicit == nil { + // The client cannot present an elicitation (no capability, or no URL to + // show); fall back to returning the instructions as a tool result. + logger.Info("surfacing github authorization instructions to user") + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: outcome.UserAction.Message}}, + }, nil + } + logger.Info("requesting github authorization via elicitation") + return &mcp.CallToolResult{ + InputRequests: mcp.InputRequestMap{oauthElicitID: elicit}, + RequestState: "github-authorization-pending", + }, nil +} + +// authorizationElicitParams builds the elicitation that presents the +// authorization instructions to the user. It mirrors sessionPrompter's channel +// selection: URL-mode when the client supports it, otherwise form-mode. It +// returns nil when the client advertised no elicitation capability or there is +// no authorization URL to show, so the caller falls back to a tool-result +// message. +func authorizationElicitParams(ua *oauth.UserAction, p *sessionPrompter) *mcp.ElicitParams { + if ua.URL == "" { + return nil + } + message := "Authorize the GitHub MCP Server to continue." + if ua.UserCode != "" { + message = fmt.Sprintf("Enter code %s to authorize the GitHub MCP Server.", ua.UserCode) + } + switch { + case p.CanPromptURL(): + return &mcp.ElicitParams{Mode: "url", Message: message, URL: ua.URL, ElicitationID: rand.Text()} + case p.CanPromptForm(): + return &mcp.ElicitParams{Mode: "form", Message: message} + default: + return nil } } diff --git a/internal/ghmcp/oauth_test.go b/internal/ghmcp/oauth_test.go index 732d080e40..5dbba67ade 100644 --- a/internal/ghmcp/oauth_test.go +++ b/internal/ghmcp/oauth_test.go @@ -140,57 +140,49 @@ func TestSessionPrompterCapabilities(t *testing.T) { } } -func TestSessionPrompterPromptActions(t *testing.T) { +// TestSessionPrompterModernProtocolUnavailable verifies that on protocol version +// 2026-07-28 and later — the default negotiated by current clients — the server +// may not initiate elicitation (SEP-2322), so PromptURL and PromptForm report +// the prompt as undeliverable. This is what routes authorization to the +// multi-round-trip path instead (see authorizeViaMultiRoundTrip). +func TestSessionPrompterModernProtocolUnavailable(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 - } + // The handler should never be reached: the SDK blocks the server-initiated + // request before it leaves the server. + handler := func(_ context.Context, _ *mcp.ElicitRequest) (*mcp.ElicitResult, error) { + return &mcp.ElicitResult{Action: "accept"}, 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() - }) + for _, mode := range []string{"url", "form"} { + t.Run(mode, func(t *testing.T) { + t.Parallel() - if tc.wantDecline { - assert.Equal(t, "declined", got) + 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 { - assert.Equal(t, "ok", got) + err = p.PromptForm(ctx, oauth.Prompt{Message: "msg"}) + } + switch { + case err == nil: + return "ok" + case errors.Is(err, oauth.ErrPromptUnavailable): + return "unavailable" + default: + return "error: " + err.Error() } }) - } + + assert.Equal(t, "unavailable", got, + "server-initiated elicitation must be reported undeliverable on protocol 2026-07-28+") + }) } } @@ -247,6 +239,15 @@ type fakeAuthenticator struct { err error authCalls int lastPrompter oauth.Prompter + + // awaitOutcome/awaitErr are returned by AwaitToken; tokenAfterAwait flips + // HasToken to true once AwaitToken is called, simulating a flow that + // acquires the token while the user acts on the elicitation. + awaitOutcome *oauth.Outcome + awaitErr error + tokenAfterAwait bool + awaitCalls int + cancelCalls int } func (f *fakeAuthenticator) HasToken() bool { return f.hasToken } @@ -257,6 +258,16 @@ func (f *fakeAuthenticator) Authenticate(_ context.Context, prompter oauth.Promp return f.outcome, f.err } +func (f *fakeAuthenticator) AwaitToken(context.Context) (*oauth.Outcome, error) { + f.awaitCalls++ + if f.tokenAfterAwait { + f.hasToken = true + } + return f.awaitOutcome, f.awaitErr +} + +func (f *fakeAuthenticator) Cancel() { f.cancelCalls++ } + func TestCreateOAuthMiddleware(t *testing.T) { t.Parallel() @@ -332,6 +343,114 @@ func TestCreateOAuthMiddleware(t *testing.T) { }) } +// runOAuthMiddlewareCall stands up an in-memory client/server pair with the +// OAuth middleware installed ahead of a probe tool, then calls the tool from a +// default (protocol 2026-07-28) client — driving the multi-round-trip +// authorization path. It returns the final tool-result text and whether the +// probe tool ultimately ran. +func runOAuthMiddlewareCall( + t *testing.T, + fake *fakeAuthenticator, + clientCaps *mcp.ClientCapabilities, + elicitationHandler func(context.Context, *mcp.ElicitRequest) (*mcp.ElicitResult, error), +) (string, bool) { + t.Helper() + + server := mcp.NewServer(&mcp.Implementation{Name: "test-server", Version: "v0.0.1"}, nil) + var toolRan bool + mcp.AddTool(server, &mcp.Tool{Name: probeToolName}, func(_ context.Context, _ *mcp.CallToolRequest, _ struct{}) (*mcp.CallToolResult, any, error) { + toolRan = true + return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: "tool-ran"}}}, nil, nil + }) + server.AddReceivingMiddleware(createOAuthMiddleware(fake, discardLogger())) + + 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, "tool result should be text content") + return text.Text, toolRan +} + +// TestOAuthMiddlewareMultiRoundTrip exercises the protocol-2026-07-28 path, where +// server-initiated elicitation is forbidden and authorization must be presented +// as a multi-round-trip input request that the client fulfills and retries. +func TestOAuthMiddlewareMultiRoundTrip(t *testing.T) { + t.Parallel() + + urlCaps := &mcp.ClientCapabilities{Elicitation: &mcp.ElicitationCapabilities{URL: &mcp.URLElicitationCapabilities{}}} + + t.Run("accepted elicitation authorizes and proceeds", func(t *testing.T) { + t.Parallel() + fake := &fakeAuthenticator{ + outcome: &oauth.Outcome{UserAction: &oauth.UserAction{URL: "https://example.com/auth", Message: "manual"}}, + tokenAfterAwait: true, + } + var elicited int + accept := func(_ context.Context, _ *mcp.ElicitRequest) (*mcp.ElicitResult, error) { + elicited++ + return &mcp.ElicitResult{Action: "accept"}, nil + } + + text, toolRan := runOAuthMiddlewareCall(t, fake, urlCaps, accept) + + assert.Equal(t, "tool-ran", text, "the tool should run once authorization completes") + assert.True(t, toolRan) + assert.Equal(t, 1, elicited, "the client should be asked to authorize exactly once") + assert.Equal(t, 1, fake.awaitCalls, "the middleware should await the token on retry") + assert.Zero(t, fake.cancelCalls) + assert.Nil(t, fake.lastPrompter, "the manager must not be given a prompter on this protocol") + }) + + t.Run("declined elicitation cancels and does not run the tool", func(t *testing.T) { + t.Parallel() + fake := &fakeAuthenticator{ + outcome: &oauth.Outcome{UserAction: &oauth.UserAction{URL: "https://example.com/auth", Message: "manual"}}, + } + decline := func(_ context.Context, _ *mcp.ElicitRequest) (*mcp.ElicitResult, error) { + return &mcp.ElicitResult{Action: "decline"}, nil + } + + text, toolRan := runOAuthMiddlewareCall(t, fake, urlCaps, decline) + + assert.False(t, toolRan, "the tool must not run when authorization is declined") + assert.Contains(t, text, "declined") + assert.Equal(t, 1, fake.cancelCalls, "a decline should cancel the in-flight flow") + assert.Zero(t, fake.awaitCalls) + }) + + t.Run("no elicitation capability falls back to a tool-result message", func(t *testing.T) { + t.Parallel() + const message = "Open https://example.com/auth to authorize, then retry." + fake := &fakeAuthenticator{ + outcome: &oauth.Outcome{UserAction: &oauth.UserAction{URL: "https://example.com/auth", Message: message}}, + } + + // No elicitation capability advertised, and no handler needed since the + // middleware should not issue an input request. + text, toolRan := runOAuthMiddlewareCall(t, fake, &mcp.ClientCapabilities{}, nil) + + assert.False(t, toolRan, "the tool must not run before authorization completes") + assert.Equal(t, message, text, "the manual instructions should be surfaced as a tool result") + assert.Zero(t, fake.awaitCalls) + assert.Zero(t, fake.cancelCalls) + }) +} + // 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 diff --git a/internal/oauth/manager.go b/internal/oauth/manager.go index a78e919df7..5ca31f831c 100644 --- a/internal/oauth/manager.go +++ b/internal/oauth/manager.go @@ -68,6 +68,7 @@ type Manager struct { status flowStatus pending *UserAction done chan struct{} + cancelFlow context.CancelFunc // cancels the in-flight flow, if any lastErr error refreshErrLogged bool // true once a refresh failure has been logged, reset on re-auth } @@ -181,6 +182,9 @@ func (m *Manager) Authenticate(ctx context.Context, prompter Prompter) (*Outcome m.mu.Unlock() bgCtx, cancel := context.WithTimeout(context.Background(), DefaultAuthTimeout) + m.mu.Lock() + m.cancelFlow = cancel + m.mu.Unlock() go m.runFlow(bgCtx, cancel, plan) if plan.userAction != nil { @@ -189,6 +193,46 @@ func (m *Manager) Authenticate(ctx context.Context, prompter Prompter) (*Outcome return m.joinWait(ctx, done) } +// AwaitToken blocks until the in-flight authorization flow yields a token, the +// flow ends without one, or ctx is done. It is the resume half of the +// multi-round-trip flow: a transport that presented the authorization prompt +// itself (via elicitation returned from a tool call) calls this once the user +// has acted, to wait for the background token acquisition to finish. +// +// It returns (nil, nil) once a token is available (proceed), (&Outcome{UserAction}, +// nil) when the user must still act out of band, or (nil, err) on failure. +func (m *Manager) AwaitToken(ctx context.Context) (*Outcome, error) { + if m.AccessToken() != "" { + return nil, nil + } + m.mu.Lock() + done := m.done + m.mu.Unlock() + if done == nil { + // No flow is in flight; report whatever terminal state it left behind. + return m.outcomeAfterFlow() + } + select { + case <-done: + return m.outcomeAfterFlow() + case <-ctx.Done(): + return nil, ctx.Err() + } +} + +// Cancel aborts the in-flight authorization flow, if any. It is used when the +// user declines the authorization prompt so the background flow (callback +// listener or device poll) is torn down promptly rather than lingering until it +// times out. It is a no-op when no flow is running. +func (m *Manager) Cancel() { + m.mu.Lock() + cancel := m.cancelFlow + m.mu.Unlock() + if cancel != nil { + cancel() + } +} + // 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 @@ -254,6 +298,7 @@ func (m *Manager) complete(tok *oauth2.Token, err error) { m.status = statusIdle m.pending = nil + m.cancelFlow = nil if err != nil { m.lastErr = err m.logger.Debug("oauth flow failed", "error", err) @@ -280,25 +325,32 @@ func (m *Manager) complete(tok *oauth2.Token, err 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") + return m.outcomeAfterFlow() case <-ctx.Done(): return nil, ctx.Err() } } +// outcomeAfterFlow reports the result once the flow's done channel has closed +// (or when there is no flow in flight): a token to proceed (nil, nil), a pending +// user action to surface, or the flow's error. +func (m *Manager) outcomeAfterFlow() (*Outcome, error) { + 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") +} + func (m *Manager) oauth2Config(redirectURL string) *oauth2.Config { return &oauth2.Config{ ClientID: m.config.ClientID, diff --git a/third-party-licenses.darwin.md b/third-party-licenses.darwin.md index 88235f3f40..fca63f2256 100644 --- a/third-party-licenses.darwin.md +++ b/third-party-licenses.darwin.md @@ -24,8 +24,8 @@ The following packages are included for the amd64, arm64 architectures. - [github.com/josephburnett/jd/v2](https://pkg.go.dev/github.com/josephburnett/jd/v2) ([MIT](https://github.com/josephburnett/jd/blob/v2.5.0/v2/LICENSE)) - [github.com/lithammer/fuzzysearch/fuzzy](https://pkg.go.dev/github.com/lithammer/fuzzysearch/fuzzy) ([MIT](https://github.com/lithammer/fuzzysearch/blob/v1.1.8/LICENSE)) - [github.com/microcosm-cc/bluemonday](https://pkg.go.dev/github.com/microcosm-cc/bluemonday) ([BSD-3-Clause](https://github.com/microcosm-cc/bluemonday/blob/v1.0.27/LICENSE.md)) - - [github.com/modelcontextprotocol/go-sdk](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk) ([Apache-2.0](https://github.com/modelcontextprotocol/go-sdk/blob/v1.7.0-pre.1/LICENSE)) - - [github.com/modelcontextprotocol/go-sdk](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk) ([MIT](https://github.com/modelcontextprotocol/go-sdk/blob/v1.7.0-pre.1/LICENSE)) + - [github.com/modelcontextprotocol/go-sdk](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk) ([Apache-2.0](https://github.com/modelcontextprotocol/go-sdk/blob/v1.7.0-pre.2/LICENSE)) + - [github.com/modelcontextprotocol/go-sdk](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk) ([MIT](https://github.com/modelcontextprotocol/go-sdk/blob/v1.7.0-pre.2/LICENSE)) - [github.com/muesli/cache2go](https://pkg.go.dev/github.com/muesli/cache2go) ([BSD-3-Clause](https://github.com/muesli/cache2go/blob/518229cd8021/LICENSE.txt)) - [github.com/pelletier/go-toml/v2](https://pkg.go.dev/github.com/pelletier/go-toml/v2) ([MIT](https://github.com/pelletier/go-toml/blob/v2.2.4/LICENSE)) - [github.com/sagikazarmark/locafero](https://pkg.go.dev/github.com/sagikazarmark/locafero) ([MIT](https://github.com/sagikazarmark/locafero/blob/v0.11.0/LICENSE)) diff --git a/third-party-licenses.linux.md b/third-party-licenses.linux.md index e3762f5c04..dc3798c769 100644 --- a/third-party-licenses.linux.md +++ b/third-party-licenses.linux.md @@ -24,8 +24,8 @@ The following packages are included for the 386, amd64, arm64 architectures. - [github.com/josephburnett/jd/v2](https://pkg.go.dev/github.com/josephburnett/jd/v2) ([MIT](https://github.com/josephburnett/jd/blob/v2.5.0/v2/LICENSE)) - [github.com/lithammer/fuzzysearch/fuzzy](https://pkg.go.dev/github.com/lithammer/fuzzysearch/fuzzy) ([MIT](https://github.com/lithammer/fuzzysearch/blob/v1.1.8/LICENSE)) - [github.com/microcosm-cc/bluemonday](https://pkg.go.dev/github.com/microcosm-cc/bluemonday) ([BSD-3-Clause](https://github.com/microcosm-cc/bluemonday/blob/v1.0.27/LICENSE.md)) - - [github.com/modelcontextprotocol/go-sdk](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk) ([Apache-2.0](https://github.com/modelcontextprotocol/go-sdk/blob/v1.7.0-pre.1/LICENSE)) - - [github.com/modelcontextprotocol/go-sdk](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk) ([MIT](https://github.com/modelcontextprotocol/go-sdk/blob/v1.7.0-pre.1/LICENSE)) + - [github.com/modelcontextprotocol/go-sdk](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk) ([Apache-2.0](https://github.com/modelcontextprotocol/go-sdk/blob/v1.7.0-pre.2/LICENSE)) + - [github.com/modelcontextprotocol/go-sdk](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk) ([MIT](https://github.com/modelcontextprotocol/go-sdk/blob/v1.7.0-pre.2/LICENSE)) - [github.com/muesli/cache2go](https://pkg.go.dev/github.com/muesli/cache2go) ([BSD-3-Clause](https://github.com/muesli/cache2go/blob/518229cd8021/LICENSE.txt)) - [github.com/pelletier/go-toml/v2](https://pkg.go.dev/github.com/pelletier/go-toml/v2) ([MIT](https://github.com/pelletier/go-toml/blob/v2.2.4/LICENSE)) - [github.com/sagikazarmark/locafero](https://pkg.go.dev/github.com/sagikazarmark/locafero) ([MIT](https://github.com/sagikazarmark/locafero/blob/v0.11.0/LICENSE)) diff --git a/third-party-licenses.windows.md b/third-party-licenses.windows.md index eb0743558a..db7db1ec1b 100644 --- a/third-party-licenses.windows.md +++ b/third-party-licenses.windows.md @@ -25,8 +25,8 @@ The following packages are included for the 386, amd64, arm64 architectures. - [github.com/josephburnett/jd/v2](https://pkg.go.dev/github.com/josephburnett/jd/v2) ([MIT](https://github.com/josephburnett/jd/blob/v2.5.0/v2/LICENSE)) - [github.com/lithammer/fuzzysearch/fuzzy](https://pkg.go.dev/github.com/lithammer/fuzzysearch/fuzzy) ([MIT](https://github.com/lithammer/fuzzysearch/blob/v1.1.8/LICENSE)) - [github.com/microcosm-cc/bluemonday](https://pkg.go.dev/github.com/microcosm-cc/bluemonday) ([BSD-3-Clause](https://github.com/microcosm-cc/bluemonday/blob/v1.0.27/LICENSE.md)) - - [github.com/modelcontextprotocol/go-sdk](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk) ([Apache-2.0](https://github.com/modelcontextprotocol/go-sdk/blob/v1.7.0-pre.1/LICENSE)) - - [github.com/modelcontextprotocol/go-sdk](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk) ([MIT](https://github.com/modelcontextprotocol/go-sdk/blob/v1.7.0-pre.1/LICENSE)) + - [github.com/modelcontextprotocol/go-sdk](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk) ([Apache-2.0](https://github.com/modelcontextprotocol/go-sdk/blob/v1.7.0-pre.2/LICENSE)) + - [github.com/modelcontextprotocol/go-sdk](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk) ([MIT](https://github.com/modelcontextprotocol/go-sdk/blob/v1.7.0-pre.2/LICENSE)) - [github.com/muesli/cache2go](https://pkg.go.dev/github.com/muesli/cache2go) ([BSD-3-Clause](https://github.com/muesli/cache2go/blob/518229cd8021/LICENSE.txt)) - [github.com/pelletier/go-toml/v2](https://pkg.go.dev/github.com/pelletier/go-toml/v2) ([MIT](https://github.com/pelletier/go-toml/blob/v2.2.4/LICENSE)) - [github.com/sagikazarmark/locafero](https://pkg.go.dev/github.com/sagikazarmark/locafero) ([MIT](https://github.com/sagikazarmark/locafero/blob/v0.11.0/LICENSE)) From 05dc8a6e3577d9c95ceef0f435e7b46614b5195c Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Tue, 14 Jul 2026 00:18:45 +0200 Subject: [PATCH 02/35] fix(oauth): harden multi-round-trip authorization Move OAuth interception into tool-handler middleware so go-sdk finalizes multi-round-trip results with resultType input_required. Correlate responses to a per-flow ID, retire cancellations synchronously, and ignore late completions from stale flows. Also preserve actionable URLs for form-only clients and add wire-level, concurrency, and real manager lifecycle coverage. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 18e70efa-1b2d-4290-ba51-b82998db4ff8 --- internal/ghmcp/oauth.go | 154 +++++++++++++----------- internal/ghmcp/oauth_test.go | 173 ++++++++++++++++++++------- internal/ghmcp/server.go | 41 +++---- internal/oauth/manager.go | 188 ++++++++++++++++++++---------- internal/oauth/manager_test.go | 117 +++++++++++++++++++ pkg/github/server.go | 7 +- pkg/inventory/registry.go | 8 +- pkg/inventory/server_tool.go | 9 +- pkg/inventory/server_tool_test.go | 45 +++++++ 9 files changed, 544 insertions(+), 198 deletions(-) diff --git a/internal/ghmcp/oauth.go b/internal/ghmcp/oauth.go index 130ada1655..35e48f5bbc 100644 --- a/internal/ghmcp/oauth.go +++ b/internal/ghmcp/oauth.go @@ -3,10 +3,13 @@ package ghmcp import ( "context" "crypto/rand" + "errors" "fmt" "log/slog" + "strings" "github.com/github/github-mcp-server/internal/oauth" + "github.com/github/github-mcp-server/pkg/inventory" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -91,14 +94,14 @@ func (p *sessionPrompter) PromptForm(ctx context.Context, prompt oauth.Prompt) e type oauthAuthenticator interface { HasToken() bool Authenticate(ctx context.Context, prompter oauth.Prompter) (*oauth.Outcome, error) - AwaitToken(ctx context.Context) (*oauth.Outcome, error) - Cancel() + AwaitToken(ctx context.Context, flowID string) (*oauth.Outcome, error) + Cancel(flowID string) bool } -// oauthElicitID is the stable key for the authorization elicitation in the -// multi-round-trip flow. The client echoes it back in InputResponses when it -// retries the tool call, so the middleware can recognize the user's response. -const oauthElicitID = "github_authorization" +// oauthElicitIDPrefix identifies authorization responses in the multi-round-trip +// InputResponses map. The suffix is the manager's per-flow ID, which prevents a +// delayed response from an older prompt from affecting a newer flow. +const oauthElicitIDPrefix = "github_authorization:" // protocolVersionNoServerElicitation is the first MCP protocol version that // forbids server-initiated JSON-RPC requests (SEP-2322): from this version on @@ -120,10 +123,11 @@ func serverMayInitiateElicitation(ss *mcp.ServerSession) bool { return params == nil || params.ProtocolVersion < protocolVersionNoServerElicitation } -// 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 and protocol version are known. +// createOAuthToolMiddleware returns tool-handler middleware that authorizes the +// session lazily, on the first tool call. It runs inside the SDK's +// Server.callTool handler so results returned here still receive SDK +// finalization, including resultType: "input_required" for multi-round-trip +// responses. // // When a token is already available the call proceeds untouched. Otherwise the // authorization flow runs, presenting its prompt over whichever channel the @@ -132,22 +136,22 @@ func serverMayInitiateElicitation(ss *mcp.ServerSession) bool { // forbidden (SEP-2322), it uses multi-round-trip elicitation returned from the // tool call. Either way the last-resort channel returns the instruction as a // tool result and asks the user 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) +func createOAuthToolMiddleware(mgr oauthAuthenticator, logger *slog.Logger) inventory.ToolHandlerMiddleware { + return func(next mcp.ToolHandler) mcp.ToolHandler { + return func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + if !serverMayInitiateElicitation(req.Session) { + if flowID, response, ok := authorizationElicitResponse(req.Params.InputResponses); ok { + return resumeMultiRoundTripAuthorization(ctx, mgr, next, req, flowID, response, logger) + } } - callReq, ok := request.(*mcp.CallToolRequest) - if !ok { - return next(ctx, method, request) + if mgr.HasToken() { + return next(ctx, req) } - - if serverMayInitiateElicitation(callReq.Session) { - return authorizeViaServerElicitation(ctx, mgr, next, method, request, callReq, logger) + if serverMayInitiateElicitation(req.Session) { + return authorizeViaServerElicitation(ctx, mgr, next, req, logger) } - return authorizeViaMultiRoundTrip(ctx, mgr, next, method, request, callReq, logger) + return startMultiRoundTripAuthorization(ctx, mgr, next, req, logger) } } } @@ -155,8 +159,8 @@ func createOAuthMiddleware(mgr oauthAuthenticator, logger *slog.Logger) func(nex // authorizeViaServerElicitation drives authorization on legacy protocol versions // (before 2026-07-28), where the server may present the prompt itself via // server-initiated elicitation. It blocks until the token arrives, then proceeds. -func authorizeViaServerElicitation(ctx context.Context, mgr oauthAuthenticator, next mcp.MethodHandler, method string, request mcp.Request, callReq *mcp.CallToolRequest, logger *slog.Logger) (mcp.Result, error) { - outcome, err := mgr.Authenticate(ctx, &sessionPrompter{session: callReq.Session}) +func authorizeViaServerElicitation(ctx context.Context, mgr oauthAuthenticator, next mcp.ToolHandler, req *mcp.CallToolRequest, logger *slog.Logger) (*mcp.CallToolResult, error) { + outcome, err := mgr.Authenticate(ctx, &sessionPrompter{session: req.Session}) if err != nil { return nil, fmt.Errorf("github authorization failed: %w", err) } @@ -166,46 +170,28 @@ func authorizeViaServerElicitation(ctx context.Context, mgr oauthAuthenticator, Content: []mcp.Content{&mcp.TextContent{Text: outcome.UserAction.Message}}, }, nil } - return next(ctx, method, request) + return next(ctx, req) } -// authorizeViaMultiRoundTrip drives authorization on protocol version 2026-07-28 -// and later, where server-initiated requests are forbidden (SEP-2322). The first -// tool call starts the flow and returns the authorization prompt as an -// elicitation input request; the client presents it and retries the call with -// the user's response, at which point we wait for the token and proceed. -func authorizeViaMultiRoundTrip(ctx context.Context, mgr oauthAuthenticator, next mcp.MethodHandler, method string, request mcp.Request, callReq *mcp.CallToolRequest, logger *slog.Logger) (mcp.Result, error) { - // Retry: the client fulfilled the authorization elicitation and re-sent the - // call with the user's response. - if resp, ok := callReq.Params.InputResponses[oauthElicitID]; ok { - res, _ := resp.(*mcp.ElicitResult) - if res == nil || res.Action != "accept" { - // The user declined or dismissed the prompt; tear the flow down so it - // does not linger, and let them retry when they are ready. - mgr.Cancel() - return &mcp.CallToolResult{ - Content: []mcp.Content{&mcp.TextContent{Text: "GitHub authorization was declined. Retry when you're ready to authorize."}}, - }, nil - } - outcome, err := mgr.AwaitToken(ctx) - if err != nil { - return nil, fmt.Errorf("github authorization failed: %w", err) +// authorizationElicitResponse finds the authorization response and extracts the +// flow ID encoded in its input-request key. +func authorizationElicitResponse(responses mcp.InputResponseMap) (string, *mcp.ElicitResult, bool) { + for id, response := range responses { + flowID, ok := strings.CutPrefix(id, oauthElicitIDPrefix) + if !ok || flowID == "" { + continue } - if outcome != nil && outcome.UserAction != nil { - // The user acknowledged the prompt but has not finished authorizing; - // surface the instructions so they can complete it and retry. - 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) + result, _ := response.(*mcp.ElicitResult) + return flowID, result, true } + return "", nil, false +} - // First attempt: start the flow. A nil prompter keeps the manager from - // initiating any elicitation itself (forbidden on this protocol); it opens a - // server-side browser when possible, otherwise returns the authorization - // instructions for us to present via multi-round-trip elicitation. +// startMultiRoundTripAuthorization starts authorization on protocol version +// 2026-07-28 or later. Server-initiated requests are forbidden there (SEP-2322), +// so the prompt is returned as an elicitation input request for the client to +// fulfill and retry. +func startMultiRoundTripAuthorization(ctx context.Context, mgr oauthAuthenticator, next mcp.ToolHandler, req *mcp.CallToolRequest, logger *slog.Logger) (*mcp.CallToolResult, error) { outcome, err := mgr.Authenticate(ctx, nil) if err != nil { return nil, fmt.Errorf("github authorization failed: %w", err) @@ -213,13 +199,14 @@ func authorizeViaMultiRoundTrip(ctx context.Context, mgr oauthAuthenticator, nex if outcome == nil || outcome.UserAction == nil { // Already authorized (e.g. the server opened a browser and the flow // completed); proceed. - return next(ctx, method, request) + return next(ctx, req) } - elicit := authorizationElicitParams(outcome.UserAction, &sessionPrompter{session: callReq.Session}) - if elicit == nil { + elicit := authorizationElicitParams(outcome.UserAction, &sessionPrompter{session: req.Session}) + if elicit == nil || outcome.FlowID == "" { // The client cannot present an elicitation (no capability, or no URL to - // show); fall back to returning the instructions as a tool result. + // show), or the flow cannot be correlated; fall back to returning the + // instructions as a tool result. logger.Info("surfacing github authorization instructions to user") return &mcp.CallToolResult{ Content: []mcp.Content{&mcp.TextContent{Text: outcome.UserAction.Message}}, @@ -227,11 +214,46 @@ func authorizeViaMultiRoundTrip(ctx context.Context, mgr oauthAuthenticator, nex } logger.Info("requesting github authorization via elicitation") return &mcp.CallToolResult{ - InputRequests: mcp.InputRequestMap{oauthElicitID: elicit}, - RequestState: "github-authorization-pending", + InputRequests: mcp.InputRequestMap{oauthElicitIDPrefix + outcome.FlowID: elicit}, }, nil } +// resumeMultiRoundTripAuthorization handles the client's retry after it +// fulfilled the authorization elicitation. +func resumeMultiRoundTripAuthorization(ctx context.Context, mgr oauthAuthenticator, next mcp.ToolHandler, req *mcp.CallToolRequest, flowID string, response *mcp.ElicitResult, logger *slog.Logger) (*mcp.CallToolResult, error) { + if response == nil || response.Action != "accept" { + if !mgr.Cancel(flowID) { + return expiredAuthorizationResult(), nil + } + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: "GitHub authorization was declined. Retry when you're ready to authorize."}}, + }, nil + } + + outcome, err := mgr.AwaitToken(ctx, flowID) + if errors.Is(err, oauth.ErrStaleAuthorizationFlow) { + return expiredAuthorizationResult(), nil + } + if err != nil { + return nil, fmt.Errorf("github authorization failed: %w", err) + } + if outcome != nil && outcome.UserAction != nil { + // The user acknowledged the prompt but has not finished authorizing; + // surface the instructions so they can complete it and retry. + logger.Info("surfacing github authorization instructions to user") + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: outcome.UserAction.Message}}, + }, nil + } + return next(ctx, req) +} + +func expiredAuthorizationResult() *mcp.CallToolResult { + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: "This GitHub authorization prompt has expired. Retry the request to authorize again."}}, + } +} + // authorizationElicitParams builds the elicitation that presents the // authorization instructions to the user. It mirrors sessionPrompter's channel // selection: URL-mode when the client supports it, otherwise form-mode. It @@ -250,7 +272,7 @@ func authorizationElicitParams(ua *oauth.UserAction, p *sessionPrompter) *mcp.El case p.CanPromptURL(): return &mcp.ElicitParams{Mode: "url", Message: message, URL: ua.URL, ElicitationID: rand.Text()} case p.CanPromptForm(): - return &mcp.ElicitParams{Mode: "form", Message: message} + return &mcp.ElicitParams{Mode: "form", Message: ua.Message} default: return nil } diff --git a/internal/ghmcp/oauth_test.go b/internal/ghmcp/oauth_test.go index 5dbba67ade..ca9b6177ac 100644 --- a/internal/ghmcp/oauth_test.go +++ b/internal/ghmcp/oauth_test.go @@ -13,6 +13,7 @@ import ( "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/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -243,11 +244,14 @@ type fakeAuthenticator struct { // awaitOutcome/awaitErr are returned by AwaitToken; tokenAfterAwait flips // HasToken to true once AwaitToken is called, simulating a flow that // acquires the token while the user acts on the elicitation. - awaitOutcome *oauth.Outcome - awaitErr error - tokenAfterAwait bool - awaitCalls int - cancelCalls int + awaitOutcome *oauth.Outcome + awaitErr error + tokenAfterAwait bool + awaitCalls int + cancelCalls int + cancelResult bool + lastAwaitFlowID string + lastCancelFlowID string } func (f *fakeAuthenticator) HasToken() bool { return f.hasToken } @@ -258,44 +262,38 @@ func (f *fakeAuthenticator) Authenticate(_ context.Context, prompter oauth.Promp return f.outcome, f.err } -func (f *fakeAuthenticator) AwaitToken(context.Context) (*oauth.Outcome, error) { +func (f *fakeAuthenticator) AwaitToken(_ context.Context, flowID string) (*oauth.Outcome, error) { f.awaitCalls++ + f.lastAwaitFlowID = flowID if f.tokenAfterAwait { f.hasToken = true } return f.awaitOutcome, f.awaitErr } -func (f *fakeAuthenticator) Cancel() { f.cancelCalls++ } +func (f *fakeAuthenticator) Cancel(flowID string) bool { + f.cancelCalls++ + f.lastCancelFlowID = flowID + return f.cancelResult +} -func TestCreateOAuthMiddleware(t *testing.T) { +func TestCreateOAuthToolMiddleware(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) { + newNext := func(called *bool) mcp.ToolHandler { + return func(_ context.Context, _ *mcp.CallToolRequest) (*mcp.CallToolResult, 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{}) + mw := createOAuthToolMiddleware(fake, discardLogger()) + _, err := mw(newNext(&called))(context.Background(), &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") @@ -305,15 +303,13 @@ func TestCreateOAuthMiddleware(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{}) + mw := createOAuthToolMiddleware(fake, discardLogger()) + res, err := mw(newNext(&called))(context.Background(), &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) + require.Len(t, res.Content, 1) + assert.Equal(t, nextText, res.Content[0].(*mcp.TextContent).Text) }) t.Run("pending user action is surfaced as a tool result", func(t *testing.T) { @@ -321,22 +317,20 @@ func TestCreateOAuthMiddleware(t *testing.T) { 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{}) + mw := createOAuthToolMiddleware(fake, discardLogger()) + res, err := mw(newNext(&called))(context.Background(), &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) + require.Len(t, res.Content, 1) + assert.Equal(t, message, res.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{}) + mw := createOAuthToolMiddleware(fake, discardLogger()) + _, err := mw(newNext(&called))(context.Background(), &mcp.CallToolRequest{}) require.Error(t, err) assert.ErrorIs(t, err, assert.AnError) assert.False(t, called, "next must not run when authentication fails") @@ -358,11 +352,14 @@ func runOAuthMiddlewareCall( server := mcp.NewServer(&mcp.Implementation{Name: "test-server", Version: "v0.0.1"}, nil) var toolRan bool - mcp.AddTool(server, &mcp.Tool{Name: probeToolName}, func(_ context.Context, _ *mcp.CallToolRequest, _ struct{}) (*mcp.CallToolResult, any, error) { + handler := createOAuthToolMiddleware(fake, discardLogger())(func(_ context.Context, _ *mcp.CallToolRequest) (*mcp.CallToolResult, error) { toolRan = true - return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: "tool-ran"}}}, nil, nil + return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: "tool-ran"}}}, nil }) - server.AddReceivingMiddleware(createOAuthMiddleware(fake, discardLogger())) + server.AddTool(&mcp.Tool{ + Name: probeToolName, + InputSchema: &jsonschema.Schema{Type: "object"}, + }, handler) st, ct := mcp.NewInMemoryTransports() @@ -397,7 +394,7 @@ func TestOAuthMiddlewareMultiRoundTrip(t *testing.T) { t.Run("accepted elicitation authorizes and proceeds", func(t *testing.T) { t.Parallel() fake := &fakeAuthenticator{ - outcome: &oauth.Outcome{UserAction: &oauth.UserAction{URL: "https://example.com/auth", Message: "manual"}}, + outcome: &oauth.Outcome{UserAction: &oauth.UserAction{URL: "https://example.com/auth", Message: "manual"}, FlowID: "flow-1"}, tokenAfterAwait: true, } var elicited int @@ -412,6 +409,7 @@ func TestOAuthMiddlewareMultiRoundTrip(t *testing.T) { assert.True(t, toolRan) assert.Equal(t, 1, elicited, "the client should be asked to authorize exactly once") assert.Equal(t, 1, fake.awaitCalls, "the middleware should await the token on retry") + assert.Equal(t, "flow-1", fake.lastAwaitFlowID) assert.Zero(t, fake.cancelCalls) assert.Nil(t, fake.lastPrompter, "the manager must not be given a prompter on this protocol") }) @@ -419,7 +417,8 @@ func TestOAuthMiddlewareMultiRoundTrip(t *testing.T) { t.Run("declined elicitation cancels and does not run the tool", func(t *testing.T) { t.Parallel() fake := &fakeAuthenticator{ - outcome: &oauth.Outcome{UserAction: &oauth.UserAction{URL: "https://example.com/auth", Message: "manual"}}, + outcome: &oauth.Outcome{UserAction: &oauth.UserAction{URL: "https://example.com/auth", Message: "manual"}, FlowID: "flow-1"}, + cancelResult: true, } decline := func(_ context.Context, _ *mcp.ElicitRequest) (*mcp.ElicitResult, error) { return &mcp.ElicitResult{Action: "decline"}, nil @@ -430,14 +429,62 @@ func TestOAuthMiddlewareMultiRoundTrip(t *testing.T) { assert.False(t, toolRan, "the tool must not run when authorization is declined") assert.Contains(t, text, "declined") assert.Equal(t, 1, fake.cancelCalls, "a decline should cancel the in-flight flow") + assert.Equal(t, "flow-1", fake.lastCancelFlowID) assert.Zero(t, fake.awaitCalls) }) + t.Run("stale decline does not cancel the current flow", func(t *testing.T) { + t.Parallel() + fake := &fakeAuthenticator{ + outcome: &oauth.Outcome{UserAction: &oauth.UserAction{URL: "https://example.com/auth", Message: "manual"}, FlowID: "old-flow"}, + } + decline := func(_ context.Context, _ *mcp.ElicitRequest) (*mcp.ElicitResult, error) { + return &mcp.ElicitResult{Action: "decline"}, nil + } + + text, toolRan := runOAuthMiddlewareCall(t, fake, urlCaps, decline) + + assert.False(t, toolRan) + assert.Contains(t, text, "expired") + assert.Equal(t, "old-flow", fake.lastCancelFlowID) + assert.Zero(t, fake.awaitCalls) + }) + + t.Run("form-only client receives actionable instructions", func(t *testing.T) { + t.Parallel() + const ( + authURL = "https://example.com/auth" + message = "Open https://example.com/auth and enter code ABCD-1234." + ) + fake := &fakeAuthenticator{ + outcome: &oauth.Outcome{ + UserAction: &oauth.UserAction{URL: authURL, UserCode: "ABCD-1234", Message: message}, + FlowID: "flow-1", + }, + tokenAfterAwait: true, + } + var elicited *mcp.ElicitParams + accept := func(_ context.Context, req *mcp.ElicitRequest) (*mcp.ElicitResult, error) { + elicited = req.Params + return &mcp.ElicitResult{Action: "accept"}, nil + } + formCaps := &mcp.ClientCapabilities{Elicitation: &mcp.ElicitationCapabilities{Form: &mcp.FormElicitationCapabilities{}}} + + text, toolRan := runOAuthMiddlewareCall(t, fake, formCaps, accept) + + assert.True(t, toolRan) + assert.Equal(t, "tool-ran", text) + require.NotNil(t, elicited) + assert.Equal(t, "form", elicited.Mode) + assert.Contains(t, elicited.Message, authURL) + assert.Contains(t, elicited.Message, "ABCD-1234") + }) + t.Run("no elicitation capability falls back to a tool-result message", func(t *testing.T) { t.Parallel() const message = "Open https://example.com/auth to authorize, then retry." fake := &fakeAuthenticator{ - outcome: &oauth.Outcome{UserAction: &oauth.UserAction{URL: "https://example.com/auth", Message: message}}, + outcome: &oauth.Outcome{UserAction: &oauth.UserAction{URL: "https://example.com/auth", Message: message}, FlowID: "flow-1"}, } // No elicitation capability advertised, and no handler needed since the @@ -451,6 +498,46 @@ func TestOAuthMiddlewareMultiRoundTrip(t *testing.T) { }) } +func TestOAuthMultiRoundTripResultType(t *testing.T) { + t.Parallel() + + fake := &fakeAuthenticator{ + outcome: &oauth.Outcome{ + UserAction: &oauth.UserAction{URL: "https://example.com/auth", Message: "manual"}, + FlowID: "flow-1", + }, + } + server := mcp.NewServer(&mcp.Implementation{Name: "test-server", Version: "v0.0.1"}, nil) + var toolRan bool + handler := createOAuthToolMiddleware(fake, discardLogger())(func(_ context.Context, _ *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + toolRan = true + return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: "tool-ran"}}}, nil + }) + server.AddTool(&mcp.Tool{ + Name: probeToolName, + InputSchema: &jsonschema.Schema{Type: "object"}, + }, handler) + + 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: &mcp.ClientCapabilities{Elicitation: &mcp.ElicitationCapabilities{URL: &mcp.URLElicitationCapabilities{}}}, + MultiRoundTrip: &mcp.MultiRoundTripOptions{Disabled: true}, + }) + 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) + assert.True(t, res.NeedsInput(), "the wire response must declare resultType input_required") + assert.Contains(t, res.InputRequests, oauthElicitIDPrefix+"flow-1") + assert.False(t, toolRan) +} + // 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 diff --git a/internal/ghmcp/server.go b/internal/ghmcp/server.go index 2267dd5d62..1e0611d648 100644 --- a/internal/ghmcp/server.go +++ b/internal/ghmcp/server.go @@ -314,38 +314,35 @@ func RunStdioServer(cfg StdioServerConfig) error { // 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 + var toolHandlerMiddleware []inventory.ToolHandlerMiddleware if cfg.OAuthManager != nil { tokenProvider = cfg.OAuthManager.AccessToken + toolHandlerMiddleware = append(toolHandlerMiddleware, createOAuthToolMiddleware(cfg.OAuthManager, logger)) } ghServer, err := NewStdioMCPServer(ctx, github.MCPServerConfig{ - Version: cfg.Version, - Host: cfg.Host, - Token: cfg.Token, - EnabledToolsets: cfg.EnabledToolsets, - EnabledTools: cfg.EnabledTools, - EnabledFeatures: cfg.EnabledFeatures, - ReadOnly: cfg.ReadOnly, - Translator: t, - ContentWindowSize: cfg.ContentWindowSize, - LockdownMode: cfg.LockdownMode, - InsidersMode: cfg.InsidersMode, - ExcludeTools: cfg.ExcludeTools, - Logger: logger, - RepoAccessTTL: cfg.RepoAccessCacheTTL, - TokenScopes: tokenScopes, - TokenProvider: tokenProvider, + Version: cfg.Version, + Host: cfg.Host, + Token: cfg.Token, + EnabledToolsets: cfg.EnabledToolsets, + EnabledTools: cfg.EnabledTools, + EnabledFeatures: cfg.EnabledFeatures, + ReadOnly: cfg.ReadOnly, + Translator: t, + ContentWindowSize: cfg.ContentWindowSize, + LockdownMode: cfg.LockdownMode, + InsidersMode: cfg.InsidersMode, + ExcludeTools: cfg.ExcludeTools, + Logger: logger, + RepoAccessTTL: cfg.RepoAccessCacheTTL, + TokenScopes: tokenScopes, + TokenProvider: tokenProvider, + ToolHandlerMiddleware: toolHandlerMiddleware, }) if err != nil { return fmt.Errorf("failed to create MCP server: %w", err) } - // With OAuth, intercept tool calls to run the authorization flow on first - // use, before the handler tries to call GitHub with an empty token. - if cfg.OAuthManager != nil { - ghServer.AddReceivingMiddleware(createOAuthMiddleware(cfg.OAuthManager, logger)) - } - if cfg.ExportTranslations { // Once server is initialized, all translations are loaded dumpTranslations() diff --git a/internal/oauth/manager.go b/internal/oauth/manager.go index 5ca31f831c..8c16729f5b 100644 --- a/internal/oauth/manager.go +++ b/internal/oauth/manager.go @@ -2,6 +2,7 @@ package oauth import ( "context" + "crypto/rand" "errors" "log/slog" "net/http" @@ -20,6 +21,10 @@ const DefaultAuthTimeout = 5 * time.Minute // stalled GitHub token endpoint cannot block a tool call indefinitely. const tokenRefreshTimeout = 30 * time.Second +// ErrStaleAuthorizationFlow indicates that a prompt response belongs to an +// authorization flow that is no longer current. +var ErrStaleAuthorizationFlow = errors.New("authorization prompt has expired") + // flowStatus tracks the manager's single-flight authorization state. type flowStatus int @@ -37,6 +42,11 @@ type Outcome struct { // flow continues in the background; the user should retry once they have // completed it. UserAction *UserAction + + // FlowID correlates a user action with the authorization flow that produced + // it. Callers must pass it back to AwaitToken or Cancel so a delayed response + // cannot affect a newer flow. + FlowID string } // UserAction is an instruction for the user to complete authorization out of @@ -65,7 +75,9 @@ type Manager struct { mu sync.Mutex source oauth2.TokenSource // refreshing source, set once authorized + tokenGeneration uint64 // increments whenever source is replaced status flowStatus + flowID string pending *UserAction done chan struct{} cancelFlow context.CancelFunc // cancels the in-flight flow, if any @@ -94,11 +106,21 @@ func NewManager(cfg Config, logger *slog.Logger) *Manager { // 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 { + token, _ := m.accessToken() + return token +} + +// accessToken returns the token together with the generation of the source it +// checked. Authenticate uses the generation to detect a source installed while +// token validation was in progress, without repeating a potentially blocking +// refresh request. +func (m *Manager) accessToken() (string, uint64) { m.mu.Lock() src := m.source + generation := m.tokenGeneration m.mu.Unlock() if src == nil { - return "" + return "", generation } // 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. @@ -110,17 +132,17 @@ func (m *Manager) AccessToken() string { // prompt. The oauth2 error carries the token endpoint's response, not the // access or refresh token. m.mu.Lock() - if !m.refreshErrLogged { + if m.tokenGeneration == generation && !m.refreshErrLogged { m.refreshErrLogged = true m.logger.Warn("OAuth token refresh failed; re-authorization required", "error", err) } m.mu.Unlock() - return "" + return "", generation } if !tok.Valid() { - return "" + return "", generation } - return tok.AccessToken + return tok.AccessToken, generation } // HasToken reports whether a valid token is currently available. @@ -138,59 +160,79 @@ func (m *Manager) HasToken() bool { // 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 - } + var flowID string + var done chan struct{} + for { + token, checkedTokenGeneration := m.accessToken() + if token != "" { + 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.Lock() + switch m.status { + case statusAwaitingUser: + ua := m.pending + flowID := m.flowID + m.mu.Unlock() + return &Outcome{UserAction: ua, FlowID: flowID}, nil + case statusStarting: + flowID := m.flowID + m.mu.Unlock() + return &Outcome{UserAction: &UserAction{ + Message: "GitHub authorization is already in progress. Please retry your request in a few seconds.", + }, FlowID: flowID}, nil + case statusInProgress: + done := m.done + flowID := m.flowID + m.mu.Unlock() + return m.joinWait(ctx, done, flowID) + } + + // A flow may have installed a token while the source above was being + // checked. Retry if the source changed before claiming the idle state. + if m.tokenGeneration != checkedTokenGeneration { + m.mu.Unlock() + continue + } + + // Idle: this call owns the new flow. + m.status = statusStarting + m.flowID = rand.Text() + flowID = m.flowID + m.lastErr = nil + m.done = make(chan struct{}) + done = m.done m.mu.Unlock() - return m.joinWait(ctx, done) + break } - // 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) + m.complete(flowID, nil, err) return nil, err } + bgCtx, cancel := context.WithTimeout(context.Background(), DefaultAuthTimeout) m.mu.Lock() + if m.flowID != flowID { + m.mu.Unlock() + cancel() + return nil, ErrStaleAuthorizationFlow + } 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) - m.mu.Lock() m.cancelFlow = cancel m.mu.Unlock() - go m.runFlow(bgCtx, cancel, plan) + go m.runFlow(bgCtx, cancel, flowID, plan) if plan.userAction != nil { - return &Outcome{UserAction: plan.userAction}, nil + return &Outcome{UserAction: plan.userAction, FlowID: flowID}, nil } - return m.joinWait(ctx, done) + return m.joinWait(ctx, done, flowID) } // AwaitToken blocks until the in-flight authorization flow yields a token, the @@ -201,43 +243,59 @@ func (m *Manager) Authenticate(ctx context.Context, prompter Prompter) (*Outcome // // It returns (nil, nil) once a token is available (proceed), (&Outcome{UserAction}, // nil) when the user must still act out of band, or (nil, err) on failure. -func (m *Manager) AwaitToken(ctx context.Context) (*Outcome, error) { - if m.AccessToken() != "" { - return nil, nil - } +func (m *Manager) AwaitToken(ctx context.Context, flowID string) (*Outcome, error) { m.mu.Lock() + if flowID == "" || flowID != m.flowID { + m.mu.Unlock() + return nil, ErrStaleAuthorizationFlow + } done := m.done m.mu.Unlock() + if m.AccessToken() != "" { + return nil, nil + } if done == nil { // No flow is in flight; report whatever terminal state it left behind. - return m.outcomeAfterFlow() + return m.outcomeAfterFlow(flowID) } select { case <-done: - return m.outcomeAfterFlow() + return m.outcomeAfterFlow(flowID) case <-ctx.Done(): return nil, ctx.Err() } } -// Cancel aborts the in-flight authorization flow, if any. It is used when the -// user declines the authorization prompt so the background flow (callback -// listener or device poll) is torn down promptly rather than lingering until it -// times out. It is a no-op when no flow is running. -func (m *Manager) Cancel() { +// Cancel retires the matching authorization flow and aborts its background +// callback listener or device poll. It returns false if flowID is stale. +func (m *Manager) Cancel(flowID string) bool { m.mu.Lock() + if flowID == "" || flowID != m.flowID { + m.mu.Unlock() + return false + } cancel := m.cancelFlow + m.status = statusIdle + m.flowID = "" + m.pending = nil + m.cancelFlow = nil + m.lastErr = context.Canceled + if m.done != nil { + close(m.done) + m.done = nil + } m.mu.Unlock() if cancel != nil { cancel() } + return true } // 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) { +func (m *Manager) runFlow(ctx context.Context, cancel context.CancelFunc, flowID string, plan *flowPlan) { defer cancel() if plan.display != nil { @@ -256,7 +314,7 @@ func (m *Manager) runFlow(ctx context.Context, cancel context.CancelFunc, plan * // 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) + m.fallBackToUserAction(flowID, plan.fallback) default: // A user decline (ErrPromptDeclined) or any other prompt failure // ends the flow. @@ -267,17 +325,17 @@ func (m *Manager) runFlow(ctx context.Context, cancel context.CancelFunc, plan * } tok, err := plan.run(ctx) - m.complete(tok, err) + m.complete(flowID, 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) { +func (m *Manager) fallBackToUserAction(flowID string, ua *UserAction) { m.mu.Lock() defer m.mu.Unlock() - if m.status != statusInProgress { + if m.flowID != flowID || m.status != statusInProgress { return } m.status = statusAwaitingUser @@ -292,9 +350,12 @@ func (m *Manager) fallBackToUserAction(ua *UserAction) { // 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) { +func (m *Manager) complete(flowID string, tok *oauth2.Token, err error) { m.mu.Lock() defer m.mu.Unlock() + if m.flowID != flowID { + return + } m.status = statusIdle m.pending = nil @@ -310,6 +371,7 @@ func (m *Manager) complete(tok *oauth2.Token, err error) { // 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.tokenGeneration++ m.refreshErrLogged = false m.logger.Info("github authorization complete") } @@ -322,10 +384,10 @@ func (m *Manager) complete(tok *oauth2.Token, err error) { // 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) { +func (m *Manager) joinWait(ctx context.Context, done chan struct{}, flowID string) (*Outcome, error) { select { case <-done: - return m.outcomeAfterFlow() + return m.outcomeAfterFlow(flowID) case <-ctx.Done(): return nil, ctx.Err() } @@ -334,16 +396,20 @@ func (m *Manager) joinWait(ctx context.Context, done chan struct{}) (*Outcome, e // outcomeAfterFlow reports the result once the flow's done channel has closed // (or when there is no flow in flight): a token to proceed (nil, nil), a pending // user action to surface, or the flow's error. -func (m *Manager) outcomeAfterFlow() (*Outcome, error) { - if m.AccessToken() != "" { - return nil, nil - } +func (m *Manager) outcomeAfterFlow(flowID string) (*Outcome, error) { m.mu.Lock() + if flowID == "" || flowID != m.flowID { + m.mu.Unlock() + return nil, ErrStaleAuthorizationFlow + } pending := m.pending err := m.lastErr m.mu.Unlock() + if m.AccessToken() != "" { + return nil, nil + } if pending != nil { - return &Outcome{UserAction: pending}, nil + return &Outcome{UserAction: pending, FlowID: flowID}, nil } if err != nil { return nil, err diff --git a/internal/oauth/manager_test.go b/internal/oauth/manager_test.go index 6f43c03ef9..52d5a54309 100644 --- a/internal/oauth/manager_test.go +++ b/internal/oauth/manager_test.go @@ -11,6 +11,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "golang.org/x/oauth2" ) // newManager wires a Manager to the fake GitHub server. By default the browser @@ -167,6 +168,7 @@ func TestAuthenticateLastDitchUserAction(t *testing.T) { require.NoError(t, err) require.NotNil(t, out) require.NotNil(t, out.UserAction) + require.NotEmpty(t, out.FlowID) assert.NotEmpty(t, out.UserAction.URL) assert.Contains(t, out.UserAction.Message, "open this URL") assert.Contains(t, out.UserAction.Message, securityAdvisory, @@ -178,12 +180,127 @@ func TestAuthenticateLastDitchUserAction(t *testing.T) { require.NoError(t, err) require.NotNil(t, out2.UserAction) assert.Equal(t, out.UserAction.URL, out2.UserAction.URL) + assert.Equal(t, out.FlowID, out2.FlowID) // 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 TestAwaitTokenCompletesCurrentFlow(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() + + out, err := m.Authenticate(ctx, nil) + require.NoError(t, err) + require.NotNil(t, out) + require.NotNil(t, out.UserAction) + require.NotEmpty(t, out.FlowID) + + authDone := make(chan error, 1) + go func() { + authDone <- browserGet(out.UserAction.URL) + }() + + awaited, err := m.AwaitToken(ctx, out.FlowID) + require.NoError(t, err) + assert.Nil(t, awaited) + require.NoError(t, <-authDone) + assert.Equal(t, "gho_access", m.AccessToken()) +} + +func TestCancelAndAwaitTokenAreFlowScoped(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() + + first, err := m.Authenticate(ctx, nil) + require.NoError(t, err) + require.NotNil(t, first) + require.NotEmpty(t, first.FlowID) + + assert.True(t, m.Cancel(first.FlowID), "the current flow should be cancelled") + second, err := m.Authenticate(ctx, nil) + require.NoError(t, err) + require.NotNil(t, second) + require.NotNil(t, second.UserAction) + require.NotEmpty(t, second.FlowID) + require.NotEqual(t, first.FlowID, second.FlowID) + + assert.False(t, m.Cancel(first.FlowID), "a stale decline must not cancel the newer flow") + _, err = m.AwaitToken(ctx, first.FlowID) + assert.ErrorIs(t, err, ErrStaleAuthorizationFlow) + + authDone := make(chan error, 1) + go func() { + authDone <- browserGet(second.UserAction.URL) + }() + awaited, err := m.AwaitToken(ctx, second.FlowID) + require.NoError(t, err) + assert.Nil(t, awaited) + require.NoError(t, <-authDone) + assert.Equal(t, "gho_access", m.AccessToken()) +} + +type blockingTokenSource struct { + entered chan struct{} + release chan struct{} + once sync.Once +} + +func (s *blockingTokenSource) Token() (*oauth2.Token, error) { + s.once.Do(func() { close(s.entered) }) + <-s.release + return nil, errors.New("stale token source failed") +} + +func TestAuthenticateRechecksTokenBeforeStartingFlow(t *testing.T) { + f := newFakeGitHub(t) + m := newManager(t, f) + staleSource := &blockingTokenSource{ + entered: make(chan struct{}), + release: make(chan struct{}), + } + + m.mu.Lock() + m.source = staleSource + m.status = statusInProgress + m.flowID = "existing-flow" + m.done = make(chan struct{}) + m.mu.Unlock() + + type result struct { + out *Outcome + err error + } + authResult := make(chan result, 1) + go func() { + out, err := m.Authenticate(context.Background(), nil) + authResult <- result{out: out, err: err} + }() + + <-staleSource.entered + m.complete("existing-flow", &oauth2.Token{ + AccessToken: "installed-token", + TokenType: "bearer", + Expiry: time.Now().Add(time.Hour), + }, nil) + close(staleSource.release) + + got := <-authResult + require.NoError(t, got.err) + assert.Nil(t, got.out) + assert.Equal(t, "installed-token", m.AccessToken()) + assert.Empty(t, f.recordedGrants(), "a completed concurrent flow must not trigger a redundant authorization") +} + func TestAuthenticateDeviceFlow(t *testing.T) { f := newFakeGitHub(t) f.deviceToken = "gho_device_token" diff --git a/pkg/github/server.go b/pkg/github/server.go index 627cc678b2..67db83a77a 100644 --- a/pkg/github/server.go +++ b/pkg/github/server.go @@ -73,6 +73,11 @@ type MCPServerConfig struct { // token is obtained lazily on first use and refreshed thereafter. TokenProvider func() string + // ToolHandlerMiddleware wraps every registered tool handler. Unlike MCP + // receiving middleware, these wrappers execute inside Server.callTool, so + // SDK result finalization still runs on results they return. + ToolHandlerMiddleware []inventory.ToolHandlerMiddleware + // Additional server options to apply ServerOptions []MCPServerOption } @@ -105,7 +110,7 @@ func NewMCPServer(ctx context.Context, cfg *MCPServerConfig, deps ToolDependenci } // Register GitHub tools/resources/prompts from the inventory. - inv.RegisterAll(ctx, ghServer, deps) + inv.RegisterAll(ctx, ghServer, deps, cfg.ToolHandlerMiddleware...) // Register MCP App UI resources whenever the embedded UI assets are // available. The resources are static HTML and are only referenced by diff --git a/pkg/inventory/registry.go b/pkg/inventory/registry.go index 6505e6b5ef..915ed0aa1c 100644 --- a/pkg/inventory/registry.go +++ b/pkg/inventory/registry.go @@ -219,9 +219,9 @@ func shouldStripMCPAppsMetadata(ctx context.Context, featureFlagEnabled bool) bo // user identity from ctx would otherwise see context.Background() and // falsely report the flag off, even when the actual request arrived on the // /insiders route. -func (r *Inventory) RegisterTools(ctx context.Context, s *mcp.Server, deps any) { +func (r *Inventory) RegisterTools(ctx context.Context, s *mcp.Server, deps any, middleware ...ToolHandlerMiddleware) { for _, tool := range r.ToolsForRegistration(ctx) { - tool.RegisterFunc(s, deps) + tool.RegisterFunc(s, deps, middleware...) } } @@ -257,8 +257,8 @@ func (r *Inventory) RegisterPrompts(ctx context.Context, s *mcp.Server) { // RegisterAll registers all available tools, resources, and prompts with the server. // The context is used for feature flag evaluation. -func (r *Inventory) RegisterAll(ctx context.Context, s *mcp.Server, deps any) { - r.RegisterTools(ctx, s, deps) +func (r *Inventory) RegisterAll(ctx context.Context, s *mcp.Server, deps any, middleware ...ToolHandlerMiddleware) { + r.RegisterTools(ctx, s, deps, middleware...) r.RegisterResourceTemplates(ctx, s, deps) r.RegisterPrompts(ctx, s) } diff --git a/pkg/inventory/server_tool.go b/pkg/inventory/server_tool.go index beb70138eb..44a062ba2e 100644 --- a/pkg/inventory/server_tool.go +++ b/pkg/inventory/server_tool.go @@ -18,6 +18,10 @@ import ( // should define their own typed dependencies struct and type-assert as needed. type HandlerFunc func(deps any) mcp.ToolHandler +// ToolHandlerMiddleware wraps an MCP tool handler. Middleware is applied from +// right to left, so the first middleware passed to RegisterFunc executes first. +type ToolHandlerMiddleware func(next mcp.ToolHandler) mcp.ToolHandler + // ToolsetID is a unique identifier for a toolset. // Using a distinct type provides compile-time type safety. type ToolsetID string @@ -110,8 +114,11 @@ func (st *ServerTool) Handler(deps any) mcp.ToolHandler { // Icons are automatically applied from the toolset metadata if not already set. // A shallow copy of the tool is made to avoid mutating the original ServerTool. // Panics if the tool has no handler - all tools should have handlers. -func (st *ServerTool) RegisterFunc(s *mcp.Server, deps any) { +func (st *ServerTool) RegisterFunc(s *mcp.Server, deps any, middleware ...ToolHandlerMiddleware) { handler := st.Handler(deps) // This will panic if HandlerFunc is nil + for i := len(middleware) - 1; i >= 0; i-- { + handler = middleware[i](handler) + } // Make a shallow copy of the tool to avoid mutating the original toolCopy := st.Tool // Apply icons from toolset metadata if tool doesn't have icons set diff --git a/pkg/inventory/server_tool_test.go b/pkg/inventory/server_tool_test.go index adf012b1f5..c6d2a6fdd8 100644 --- a/pkg/inventory/server_tool_test.go +++ b/pkg/inventory/server_tool_test.go @@ -81,6 +81,51 @@ func TestNewServerToolWithContextHandler_ValidArguments_Succeeds(t *testing.T) { assert.Equal(t, "success: octocat/hello-world", textContent.Text) } +func TestServerToolRegisterFuncAppliesMiddleware(t *testing.T) { + tool := NewServerTool( + mcp.Tool{ + Name: "wrapped_tool", + InputSchema: &jsonschema.Schema{Type: "object"}, + }, + testToolsetMetadata("test"), + func(_ context.Context, _ *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: "handler"}}, + }, nil + }, + ) + + middlewareCalled := make(chan struct{}, 1) + middleware := func(next mcp.ToolHandler) mcp.ToolHandler { + return func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + middlewareCalled <- struct{}{} + return next(ctx, req) + } + } + + server := mcp.NewServer(&mcp.Implementation{Name: "test-server", Version: "v0.0.1"}, nil) + tool.RegisterFunc(server, nil, middleware) + 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"}, nil) + cs, err := client.Connect(context.Background(), ct, nil) + require.NoError(t, err) + t.Cleanup(func() { _ = cs.Close() }) + + result, err := cs.CallTool(context.Background(), &mcp.CallToolParams{Name: "wrapped_tool"}) + require.NoError(t, err) + select { + case <-middlewareCalled: + default: + t.Fatal("tool middleware was not called") + } + require.Len(t, result.Content, 1) + assert.Equal(t, "handler", result.Content[0].(*mcp.TextContent).Text) +} + func TestAnnotateHeaderParams(t *testing.T) { tool := &mcp.Tool{InputSchema: &jsonschema.Schema{ Type: "object", From 9a33e071f6f2b1db4d8a08bd65d4f7a0db1b3765 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 6 Jul 2026 16:23:49 +0000 Subject: [PATCH 03/35] build(deps): bump github.com/go-chi/chi/v5 from 5.3.0 to 5.3.1 Bumps [github.com/go-chi/chi/v5](https://github.com/go-chi/chi) from 5.3.0 to 5.3.1. - [Release notes](https://github.com/go-chi/chi/releases) - [Changelog](https://github.com/go-chi/chi/blob/master/CHANGELOG.md) - [Commits](https://github.com/go-chi/chi/compare/v5.3.0...v5.3.1) --- updated-dependencies: - dependency-name: github.com/go-chi/chi/v5 dependency-version: 5.3.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index b11455139d..d553a10509 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/github/github-mcp-server go 1.25.0 require ( - github.com/go-chi/chi/v5 v5.3.0 + github.com/go-chi/chi/v5 v5.3.1 github.com/go-viper/mapstructure/v2 v2.5.0 github.com/google/go-github/v89 v89.0.0 github.com/google/jsonschema-go v0.4.3 diff --git a/go.sum b/go.sum index 64e5b47839..4aa9805e04 100644 --- a/go.sum +++ b/go.sum @@ -7,8 +7,8 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= -github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM= -github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= +github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8= +github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= From e6b6e3eb22fe6c038f62ec509a121042291fcbf6 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 6 Jul 2026 16:25:00 +0000 Subject: [PATCH 04/35] chore: regenerate license files Auto-generated by license-check workflow --- third-party-licenses.darwin.md | 2 +- third-party-licenses.linux.md | 2 +- third-party-licenses.windows.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/third-party-licenses.darwin.md b/third-party-licenses.darwin.md index fca63f2256..111bf76d7d 100644 --- a/third-party-licenses.darwin.md +++ b/third-party-licenses.darwin.md @@ -15,7 +15,7 @@ The following packages are included for the amd64, arm64 architectures. - [github.com/aymerick/douceur](https://pkg.go.dev/github.com/aymerick/douceur) ([MIT](https://github.com/aymerick/douceur/blob/v0.2.0/LICENSE)) - [github.com/fsnotify/fsnotify](https://pkg.go.dev/github.com/fsnotify/fsnotify) ([BSD-3-Clause](https://github.com/fsnotify/fsnotify/blob/v1.9.0/LICENSE)) - [github.com/github/github-mcp-server](https://pkg.go.dev/github.com/github/github-mcp-server) ([MIT](https://github.com/github/github-mcp-server/blob/HEAD/LICENSE)) - - [github.com/go-chi/chi/v5](https://pkg.go.dev/github.com/go-chi/chi/v5) ([MIT](https://github.com/go-chi/chi/blob/v5.3.0/LICENSE)) + - [github.com/go-chi/chi/v5](https://pkg.go.dev/github.com/go-chi/chi/v5) ([MIT](https://github.com/go-chi/chi/blob/v5.3.1/LICENSE)) - [github.com/go-viper/mapstructure/v2](https://pkg.go.dev/github.com/go-viper/mapstructure/v2) ([MIT](https://github.com/go-viper/mapstructure/blob/v2.5.0/LICENSE)) - [github.com/google/go-github/v89/github](https://pkg.go.dev/github.com/google/go-github/v89/github) ([BSD-3-Clause](https://github.com/google/go-github/blob/v89.0.0/LICENSE)) - [github.com/google/go-querystring/query](https://pkg.go.dev/github.com/google/go-querystring/query) ([BSD-3-Clause](https://github.com/google/go-querystring/blob/v1.2.0/LICENSE)) diff --git a/third-party-licenses.linux.md b/third-party-licenses.linux.md index dc3798c769..2e76b2885f 100644 --- a/third-party-licenses.linux.md +++ b/third-party-licenses.linux.md @@ -15,7 +15,7 @@ The following packages are included for the 386, amd64, arm64 architectures. - [github.com/aymerick/douceur](https://pkg.go.dev/github.com/aymerick/douceur) ([MIT](https://github.com/aymerick/douceur/blob/v0.2.0/LICENSE)) - [github.com/fsnotify/fsnotify](https://pkg.go.dev/github.com/fsnotify/fsnotify) ([BSD-3-Clause](https://github.com/fsnotify/fsnotify/blob/v1.9.0/LICENSE)) - [github.com/github/github-mcp-server](https://pkg.go.dev/github.com/github/github-mcp-server) ([MIT](https://github.com/github/github-mcp-server/blob/HEAD/LICENSE)) - - [github.com/go-chi/chi/v5](https://pkg.go.dev/github.com/go-chi/chi/v5) ([MIT](https://github.com/go-chi/chi/blob/v5.3.0/LICENSE)) + - [github.com/go-chi/chi/v5](https://pkg.go.dev/github.com/go-chi/chi/v5) ([MIT](https://github.com/go-chi/chi/blob/v5.3.1/LICENSE)) - [github.com/go-viper/mapstructure/v2](https://pkg.go.dev/github.com/go-viper/mapstructure/v2) ([MIT](https://github.com/go-viper/mapstructure/blob/v2.5.0/LICENSE)) - [github.com/google/go-github/v89/github](https://pkg.go.dev/github.com/google/go-github/v89/github) ([BSD-3-Clause](https://github.com/google/go-github/blob/v89.0.0/LICENSE)) - [github.com/google/go-querystring/query](https://pkg.go.dev/github.com/google/go-querystring/query) ([BSD-3-Clause](https://github.com/google/go-querystring/blob/v1.2.0/LICENSE)) diff --git a/third-party-licenses.windows.md b/third-party-licenses.windows.md index db7db1ec1b..4c66b80842 100644 --- a/third-party-licenses.windows.md +++ b/third-party-licenses.windows.md @@ -15,7 +15,7 @@ The following packages are included for the 386, amd64, arm64 architectures. - [github.com/aymerick/douceur](https://pkg.go.dev/github.com/aymerick/douceur) ([MIT](https://github.com/aymerick/douceur/blob/v0.2.0/LICENSE)) - [github.com/fsnotify/fsnotify](https://pkg.go.dev/github.com/fsnotify/fsnotify) ([BSD-3-Clause](https://github.com/fsnotify/fsnotify/blob/v1.9.0/LICENSE)) - [github.com/github/github-mcp-server](https://pkg.go.dev/github.com/github/github-mcp-server) ([MIT](https://github.com/github/github-mcp-server/blob/HEAD/LICENSE)) - - [github.com/go-chi/chi/v5](https://pkg.go.dev/github.com/go-chi/chi/v5) ([MIT](https://github.com/go-chi/chi/blob/v5.3.0/LICENSE)) + - [github.com/go-chi/chi/v5](https://pkg.go.dev/github.com/go-chi/chi/v5) ([MIT](https://github.com/go-chi/chi/blob/v5.3.1/LICENSE)) - [github.com/go-viper/mapstructure/v2](https://pkg.go.dev/github.com/go-viper/mapstructure/v2) ([MIT](https://github.com/go-viper/mapstructure/blob/v2.5.0/LICENSE)) - [github.com/google/go-github/v89/github](https://pkg.go.dev/github.com/google/go-github/v89/github) ([BSD-3-Clause](https://github.com/google/go-github/blob/v89.0.0/LICENSE)) - [github.com/google/go-querystring/query](https://pkg.go.dev/github.com/google/go-querystring/query) ([BSD-3-Clause](https://github.com/google/go-querystring/blob/v1.2.0/LICENSE)) From ba72f069a595d4457c47bf1ea533aa216ac89ce5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 7 Jul 2026 12:03:00 +0000 Subject: [PATCH 05/35] build(deps): bump docker/metadata-action from 6.1.0 to 6.2.0 Bumps [docker/metadata-action](https://github.com/docker/metadata-action) from 6.1.0 to 6.2.0. - [Release notes](https://github.com/docker/metadata-action/releases) - [Commits](https://github.com/docker/metadata-action/compare/80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9...dc802804100637a589fabce1cb79ff13a1411302) --- updated-dependencies: - dependency-name: docker/metadata-action dependency-version: 6.2.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- .github/workflows/docker-publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 51c3e8d8b0..2782d3b106 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -70,7 +70,7 @@ jobs: # https://github.com/docker/metadata-action - name: Extract Docker metadata id: meta - uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0 + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} tags: | From bb96e9bf46dce061260e21351a457919e9fea1bf Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 7 Jul 2026 12:02:59 +0000 Subject: [PATCH 06/35] build(deps): bump docker/setup-buildx-action from 4.1.0 to 4.2.0 Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 4.1.0 to 4.2.0. - [Release notes](https://github.com/docker/setup-buildx-action/releases) - [Commits](https://github.com/docker/setup-buildx-action/compare/d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5...bb05f3f5519dd87d3ba754cc423b652a5edd6d2c) --- updated-dependencies: - dependency-name: docker/setup-buildx-action dependency-version: 4.2.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- .github/workflows/docker-publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 2782d3b106..8ff4fca3e1 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -54,7 +54,7 @@ jobs: # multi-platform images and export cache # https://github.com/docker/setup-buildx-action - name: Set up Docker Buildx - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 # Login against a Docker registry except on PR # https://github.com/docker/login-action From d69cdc59f10aedf673a8e4c2ca38c668204df992 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 7 Jul 2026 12:03:05 +0000 Subject: [PATCH 07/35] build(deps): bump docker/login-action from 4.2.0 to 4.4.0 Bumps [docker/login-action](https://github.com/docker/login-action) from 4.2.0 to 4.4.0. - [Release notes](https://github.com/docker/login-action/releases) - [Commits](https://github.com/docker/login-action/compare/650006c6eb7dba73a995cc03b0b2d7f5ca915bee...af1e73f918a031802d376d3c8bbc3fe56130a9b0) --- updated-dependencies: - dependency-name: docker/login-action dependency-version: 4.4.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- .github/workflows/docker-publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 8ff4fca3e1..ddd3c43991 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -60,7 +60,7 @@ jobs: # https://github.com/docker/login-action - name: Log into registry ${{ env.REGISTRY }} if: github.event_name != 'pull_request' - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} From e62ce5fddecbac59ae68c42d361b4e529795fda5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 13 Jul 2026 16:23:32 +0000 Subject: [PATCH 08/35] build(deps): bump golang from 1.25.11-alpine to 1.25.12-alpine Bumps golang from 1.25.11-alpine to 1.25.12-alpine. --- updated-dependencies: - dependency-name: golang dependency-version: 1.25.12-alpine dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 688e5470aa..04cfb7125d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,7 +7,7 @@ COPY ui/ ./ui/ RUN mkdir -p ./pkg/github/ui_dist && \ cd ui && npm run build -FROM golang:1.25.11-alpine@sha256:523c3effe300580ed375e43f43b1c9b091b68e935a7c3a92bfcc4e7ed55b18c2 AS build +FROM golang:1.25.12-alpine@sha256:56961d79ea8129efddcc0b8643fd8a5416b4e6228cfd477e3fd61deb2672c587 AS build ARG VERSION="dev" # Set the working directory From 8ac674b0562bf2d2cccaea2965d32fcb2511cdbe Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 13 Jul 2026 16:23:34 +0000 Subject: [PATCH 09/35] build(deps): bump distroless/base-debian12 from `e7e678c` to `9c05cfd` Bumps distroless/base-debian12 from `e7e678c` to `9c05cfd`. --- updated-dependencies: - dependency-name: distroless/base-debian12 dependency-version: 9c05cfd65f41c93a909ea67eb05b920a3b838780ea55df5421d48295d98ff957 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 04cfb7125d..acf38a77dd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -35,7 +35,7 @@ RUN --mount=type=cache,target=/go/pkg/mod \ -o /bin/github-mcp-server ./cmd/github-mcp-server # Make a stage to run the app -FROM gcr.io/distroless/base-debian12@sha256:e7e678c88c59e70e105a46549bb3fbfb3d732ee3b4afd3a19fdab2e15afaa6b3 +FROM gcr.io/distroless/base-debian12@sha256:9c05cfd65f41c93a909ea67eb05b920a3b838780ea55df5421d48295d98ff957 # Add required MCP server annotation LABEL io.modelcontextprotocol.server.name="io.github.github/github-mcp-server" From b463b647ce6fbbad296443d50eafb605a14ac55d Mon Sep 17 00:00:00 2001 From: kerobbi Date: Tue, 14 Jul 2026 09:44:21 +0100 Subject: [PATCH 10/35] centralise lockdown checks and default fail closed on empty author --- pkg/github/issues.go | 14 ++------- pkg/github/lockdown.go | 38 ++++++++++++++++++++++ pkg/github/lockdown_test.go | 29 +++++++++++++++++ pkg/github/pullrequests.go | 15 ++------- pkg/github/pullrequests_test.go | 56 ++++++++++++++++++++++++++++----- 5 files changed, 119 insertions(+), 33 deletions(-) create mode 100644 pkg/github/lockdown.go create mode 100644 pkg/github/lockdown_test.go diff --git a/pkg/github/issues.go b/pkg/github/issues.go index bbccdc35d8..0f12804a59 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -734,18 +734,8 @@ func GetIssue(ctx context.Context, client *github.Client, deps ToolDependencies, } if flags.LockdownMode { - if cache == nil { - return nil, fmt.Errorf("lockdown cache is not configured") - } - login := issue.GetUser().GetLogin() - if login != "" { - isSafeContent, err := cache.IsSafeContent(ctx, login, owner, repo) - if err != nil { - return utils.NewToolResultError(fmt.Sprintf("failed to check lockdown mode: %v", err)), nil - } - if !isSafeContent { - return utils.NewToolResultError("access to issue details is restricted by lockdown mode"), nil - } + if restricted, err := authorLockdownResult(ctx, cache, owner, repo, issue.GetUser().GetLogin(), lockdownIssueRestrictedMessage); restricted != nil || err != nil { + return restricted, err } } diff --git a/pkg/github/lockdown.go b/pkg/github/lockdown.go new file mode 100644 index 0000000000..1d3a687028 --- /dev/null +++ b/pkg/github/lockdown.go @@ -0,0 +1,38 @@ +package github + +import ( + "context" + "fmt" + + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/github/github-mcp-server/pkg/lockdown" + "github.com/github/github-mcp-server/pkg/utils" +) + +// Restriction messages returned when lockdown mode withholds content from a read tool. +const ( + lockdownPullRequestRestrictedMessage = "access to pull request is restricted by lockdown mode" + lockdownIssueRestrictedMessage = "access to issue details is restricted by lockdown mode" +) + +// authorLockdownResult returns a restricted tool result when content authored by +// authorLogin cannot be surfaced for owner/repo under lockdown mode, and (nil, nil) +// when access is permitted. It should only be called when lockdown mode is enabled. +// It fails closed: a missing cache, an empty author, or a lookup error denies access. +func authorLockdownResult(ctx context.Context, cache *lockdown.RepoAccessCache, owner, repo, authorLogin, restrictedMessage string) (*mcp.CallToolResult, error) { + if cache == nil { + return nil, fmt.Errorf("lockdown cache is not configured") + } + if authorLogin == "" { + return utils.NewToolResultError(restrictedMessage), nil + } + isSafeContent, err := cache.IsSafeContent(ctx, authorLogin, owner, repo) + if err != nil { + return utils.NewToolResultError(fmt.Sprintf("failed to check lockdown mode: %v", err)), nil + } + if !isSafeContent { + return utils.NewToolResultError(restrictedMessage), nil + } + return nil, nil +} diff --git a/pkg/github/lockdown_test.go b/pkg/github/lockdown_test.go new file mode 100644 index 0000000000..2340a88ec2 --- /dev/null +++ b/pkg/github/lockdown_test.go @@ -0,0 +1,29 @@ +package github + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_authorLockdownResult(t *testing.T) { + t.Parallel() + + t.Run("missing cache returns error", func(t *testing.T) { + result, err := authorLockdownResult(context.Background(), nil, "owner", "repo", "author", lockdownIssueRestrictedMessage) + require.Error(t, err) + assert.Nil(t, result) + }) + + t.Run("empty author fails closed", func(t *testing.T) { + cache := stubRepoAccessCache(nil, time.Minute) + result, err := authorLockdownResult(context.Background(), cache, "owner", "repo", "", lockdownIssueRestrictedMessage) + require.NoError(t, err) + require.NotNil(t, result) + assert.True(t, result.IsError) + assert.Contains(t, getErrorResult(t, result).Text, lockdownIssueRestrictedMessage) + }) +} diff --git a/pkg/github/pullrequests.go b/pkg/github/pullrequests.go index 942cfd3a91..92cf156b80 100644 --- a/pkg/github/pullrequests.go +++ b/pkg/github/pullrequests.go @@ -196,19 +196,8 @@ func GetPullRequest(ctx context.Context, client *github.Client, deps ToolDepende } if ff.LockdownMode { - if cache == nil { - return nil, fmt.Errorf("lockdown cache is not configured") - } - login := pr.GetUser().GetLogin() - if login != "" { - isSafeContent, err := cache.IsSafeContent(ctx, login, owner, repo) - if err != nil { - return nil, fmt.Errorf("failed to check content removal: %w", err) - } - - if !isSafeContent { - return utils.NewToolResultError("access to pull request is restricted by lockdown mode"), nil - } + if restricted, err := authorLockdownResult(ctx, cache, owner, repo, pr.GetUser().GetLogin(), lockdownPullRequestRestrictedMessage); restricted != nil || err != nil { + return restricted, err } } diff --git a/pkg/github/pullrequests_test.go b/pkg/github/pullrequests_test.go index c032502c97..1076befd58 100644 --- a/pkg/github/pullrequests_test.go +++ b/pkg/github/pullrequests_test.go @@ -53,12 +53,14 @@ func Test_GetPullRequest(t *testing.T) { } tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]any - expectError bool - expectedPR *github.PullRequest - expectedErrMsg string + name string + mockedClient *http.Client + requestArgs map[string]any + expectError bool + expectedPR *github.PullRequest + expectedErrMsg string + lockdownEnabled bool + restPermission string }{ { name: "successful PR fetch", @@ -91,6 +93,38 @@ func Test_GetPullRequest(t *testing.T) { expectError: true, expectedErrMsg: "failed to get pull request", }, + { + name: "lockdown enabled - user lacks push access", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposPullsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, mockPR), + }), + requestArgs: map[string]any{ + "method": "get", + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + }, + expectError: true, + expectedErrMsg: "access to pull request is restricted by lockdown mode", + lockdownEnabled: true, + restPermission: "read", + }, + { + name: "lockdown enabled - private repository", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposPullsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, mockPR), + }), + requestArgs: map[string]any{ + "method": "get", + "owner": "owner2", + "repo": "repo2", + "pullNumber": float64(42), + }, + expectError: false, + expectedPR: mockPR, + lockdownEnabled: true, + restPermission: "none", + }, } for _, tc := range tests { @@ -98,11 +132,17 @@ func Test_GetPullRequest(t *testing.T) { // Setup client with mock client := mustNewGHClient(t, tc.mockedClient) gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient()) + + var restClient *github.Client + if tc.restPermission != "" { + restClient = mockRESTPermissionServer(t, tc.restPermission, nil) + } + deps := BaseDeps{ Client: client, GQLClient: gqlClient, - RepoAccessCache: stubRepoAccessCache(nil, 5*time.Minute), - Flags: stubFeatureFlags(map[string]bool{"lockdown-mode": false}), + RepoAccessCache: stubRepoAccessCache(restClient, 5*time.Minute), + Flags: stubFeatureFlags(map[string]bool{"lockdown-mode": tc.lockdownEnabled}), } handler := serverTool.Handler(deps) From 5a0beacbcb856bbbcbe65f16f539a307f9e26d03 Mon Sep 17 00:00:00 2001 From: kerobbi Date: Tue, 14 Jul 2026 13:31:17 +0100 Subject: [PATCH 11/35] test lockdown lookup-failure returns tool-result error --- pkg/github/lockdown_test.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/pkg/github/lockdown_test.go b/pkg/github/lockdown_test.go index 2340a88ec2..efba381147 100644 --- a/pkg/github/lockdown_test.go +++ b/pkg/github/lockdown_test.go @@ -26,4 +26,13 @@ func Test_authorLockdownResult(t *testing.T) { assert.True(t, result.IsError) assert.Contains(t, getErrorResult(t, result).Text, lockdownIssueRestrictedMessage) }) + + t.Run("lookup failure returns tool-result error", func(t *testing.T) { + cache := stubRepoAccessCache(nil, time.Minute) + result, err := authorLockdownResult(context.Background(), cache, "owner", "repo", "author", lockdownIssueRestrictedMessage) + require.NoError(t, err) + require.NotNil(t, result) + assert.True(t, result.IsError) + assert.Contains(t, getErrorResult(t, result).Text, "failed to check lockdown mode") + }) } From 0ad8cc67f3777760b9990a21187e19a0fcb0c921 Mon Sep 17 00:00:00 2001 From: kerobbi Date: Tue, 14 Jul 2026 11:16:49 +0100 Subject: [PATCH 12/35] enforce lockdown on pr diff/files/check_runs and fix reviews fail-open --- pkg/github/pullrequests.go | 78 +++++++++--- pkg/github/pullrequests_test.go | 217 ++++++++++++++++++++++++++++++-- 2 files changed, 268 insertions(+), 27 deletions(-) diff --git a/pkg/github/pullrequests.go b/pkg/github/pullrequests.go index 92cf156b80..e36096bc9c 100644 --- a/pkg/github/pullrequests.go +++ b/pkg/github/pullrequests.go @@ -123,13 +123,13 @@ Possible options: result, err := GetPullRequest(ctx, client, deps, owner, repo, pullNumber) return attachIFC(result), nil, err case "get_diff": - result, err := GetPullRequestDiff(ctx, client, owner, repo, pullNumber) + result, err := GetPullRequestDiff(ctx, client, deps, owner, repo, pullNumber) return attachIFC(result), nil, err case "get_status": result, err := GetPullRequestStatus(ctx, client, owner, repo, pullNumber) return attachIFC(result), nil, err case "get_files": - result, err := GetPullRequestFiles(ctx, client, owner, repo, pullNumber, pagination) + result, err := GetPullRequestFiles(ctx, client, deps, owner, repo, pullNumber, pagination) return attachIFC(result), nil, err case "get_commits": result, err := GetPullRequestCommits(ctx, client, owner, repo, pullNumber, pagination) @@ -152,7 +152,7 @@ Possible options: result, err := GetIssueComments(ctx, client, deps, owner, repo, pullNumber, pagination) return attachIFC(result), nil, err case "get_check_runs": - result, err := GetPullRequestCheckRuns(ctx, client, owner, repo, pullNumber, pagination) + result, err := GetPullRequestCheckRuns(ctx, client, deps, owner, repo, pullNumber, pagination) return attachIFC(result), nil, err default: return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil @@ -206,7 +206,40 @@ func GetPullRequest(ctx context.Context, client *github.Client, deps ToolDepende return MarshalledTextResult(minimalPR), nil } -func GetPullRequestDiff(ctx context.Context, client *github.Client, owner, repo string, pullNumber int) (*mcp.CallToolResult, error) { +// enforcePullRequestLockdown returns a restricted tool result when lockdown mode is +// enabled and the pull request author is not a safe content source for owner/repo, +// and (nil, nil) otherwise. It fetches the pull request to resolve the author and is +// a no-op that performs no request when lockdown mode is disabled. +func enforcePullRequestLockdown(ctx context.Context, client *github.Client, deps ToolDependencies, owner, repo string, pullNumber int) (*mcp.CallToolResult, error) { + if !deps.GetFlags(ctx).LockdownMode { + return nil, nil + } + cache, err := deps.GetRepoAccessCache(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get repo access cache: %w", err) + } + pr, resp, err := client.PullRequests.Get(ctx, owner, repo, pullNumber) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get pull request", resp, err), nil + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get pull request", resp, body), nil + } + + return authorLockdownResult(ctx, cache, owner, repo, pr.GetUser().GetLogin(), lockdownPullRequestRestrictedMessage) +} + +func GetPullRequestDiff(ctx context.Context, client *github.Client, deps ToolDependencies, owner, repo string, pullNumber int) (*mcp.CallToolResult, error) { + if restricted, err := enforcePullRequestLockdown(ctx, client, deps, owner, repo, pullNumber); restricted != nil || err != nil { + return restricted, err + } + raw, resp, err := client.PullRequests.GetRaw( ctx, owner, @@ -282,7 +315,7 @@ func GetPullRequestStatus(ctx context.Context, client *github.Client, owner, rep return utils.NewToolResultText(string(r)), nil } -func GetPullRequestCheckRuns(ctx context.Context, client *github.Client, owner, repo string, pullNumber int, pagination PaginationParams) (*mcp.CallToolResult, error) { +func GetPullRequestCheckRuns(ctx context.Context, client *github.Client, deps ToolDependencies, owner, repo string, pullNumber int, pagination PaginationParams) (*mcp.CallToolResult, error) { // First get the PR to get the head SHA pr, resp, err := client.PullRequests.Get(ctx, owner, repo, pullNumber) if err != nil { @@ -302,6 +335,16 @@ func GetPullRequestCheckRuns(ctx context.Context, client *github.Client, owner, return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get pull request", resp, body), nil } + if deps.GetFlags(ctx).LockdownMode { + cache, err := deps.GetRepoAccessCache(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get repo access cache: %w", err) + } + if restricted, err := authorLockdownResult(ctx, cache, owner, repo, pr.GetUser().GetLogin(), lockdownPullRequestRestrictedMessage); restricted != nil || err != nil { + return restricted, err + } + } + // Get check runs for the head SHA opts := &github.ListCheckRunsOptions{ ListOptions: github.ListOptions{ @@ -347,7 +390,11 @@ func GetPullRequestCheckRuns(ctx context.Context, client *github.Client, owner, return utils.NewToolResultText(string(r)), nil } -func GetPullRequestFiles(ctx context.Context, client *github.Client, owner, repo string, pullNumber int, pagination PaginationParams) (*mcp.CallToolResult, error) { +func GetPullRequestFiles(ctx context.Context, client *github.Client, deps ToolDependencies, owner, repo string, pullNumber int, pagination PaginationParams) (*mcp.CallToolResult, error) { + if restricted, err := enforcePullRequestLockdown(ctx, client, deps, owner, repo, pullNumber); restricted != nil || err != nil { + return restricted, err + } + opts := &github.ListOptions{ PerPage: pagination.PerPage, Page: pagination.Page, @@ -552,17 +599,18 @@ func GetPullRequestReviews(ctx context.Context, client *github.Client, deps Tool filteredReviews := make([]*github.PullRequestReview, 0, len(reviews)) for _, review := range reviews { login := review.GetUser().GetLogin() - if login != "" { - isSafeContent, err := cache.IsSafeContent(ctx, login, owner, repo) - if err != nil { - return nil, fmt.Errorf("failed to check lockdown mode: %w", err) - } - if isSafeContent { - filteredReviews = append(filteredReviews, review) - } - reviews = filteredReviews + if login == "" { + continue + } + isSafeContent, err := cache.IsSafeContent(ctx, login, owner, repo) + if err != nil { + return nil, fmt.Errorf("failed to check lockdown mode: %w", err) + } + if isSafeContent { + filteredReviews = append(filteredReviews, review) } } + reviews = filteredReviews } minimalReviews := make([]MinimalPullRequestReview, 0, len(reviews)) diff --git a/pkg/github/pullrequests_test.go b/pkg/github/pullrequests_test.go index 1076befd58..acf4abdaa5 100644 --- a/pkg/github/pullrequests_test.go +++ b/pkg/github/pullrequests_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "net/http" + "strings" "sync/atomic" "testing" "time" @@ -1192,12 +1193,14 @@ func Test_GetPullRequestFiles(t *testing.T) { } tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]any - expectError bool - expectedFiles []*github.CommitFile - expectedErrMsg string + name string + mockedClient *http.Client + requestArgs map[string]any + expectError bool + expectedFiles []*github.CommitFile + expectedErrMsg string + lockdownEnabled bool + restPermission string }{ { name: "successful files fetch", @@ -1261,6 +1264,64 @@ func Test_GetPullRequestFiles(t *testing.T) { expectError: true, expectedErrMsg: "failed to get pull request files", }, + { + name: "lockdown enabled - author lacks push access", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposPullsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, &github.PullRequest{ + Number: github.Ptr(42), + User: &github.User{Login: github.Ptr("reader")}, + }), + }), + requestArgs: map[string]any{ + "method": "get_files", + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + }, + lockdownEnabled: true, + restPermission: "read", + expectError: true, + expectedErrMsg: "access to pull request is restricted by lockdown mode", + }, + { + name: "lockdown enabled - author has push access", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposPullsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, &github.PullRequest{ + Number: github.Ptr(42), + User: &github.User{Login: github.Ptr("writer")}, + }), + GetReposPullsFilesByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, mockFiles), + }), + requestArgs: map[string]any{ + "method": "get_files", + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + }, + lockdownEnabled: true, + restPermission: "write", + expectError: false, + expectedFiles: mockFiles, + }, + { + name: "lockdown enabled - pull request fetch fails", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposPullsByOwnerByRepoByPullNumber: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + }), + }), + requestArgs: map[string]any{ + "method": "get_files", + "owner": "owner", + "repo": "repo", + "pullNumber": float64(999), + }, + lockdownEnabled: true, + restPermission: "read", + expectError: true, + expectedErrMsg: "failed to get pull request", + }, } for _, tc := range tests { @@ -1268,10 +1329,16 @@ func Test_GetPullRequestFiles(t *testing.T) { // Setup client with mock client := mustNewGHClient(t, tc.mockedClient) serverTool := PullRequestRead(translations.NullTranslationHelper) + + var restClient *github.Client + if tc.lockdownEnabled { + restClient = mockRESTPermissionServer(t, tc.restPermission, nil) + } + deps := BaseDeps{ Client: client, - RepoAccessCache: stubRepoAccessCache(nil, 5*time.Minute), - Flags: stubFeatureFlags(map[string]bool{"lockdown-mode": false}), + RepoAccessCache: stubRepoAccessCache(restClient, 5*time.Minute), + Flags: stubFeatureFlags(map[string]bool{"lockdown-mode": tc.lockdownEnabled}), } handler := serverTool.Handler(deps) @@ -1675,6 +1742,7 @@ func Test_GetPullRequestCheckRuns(t *testing.T) { SHA: github.Ptr("abcd1234"), Ref: github.Ptr("feature-branch"), }, + User: &github.User{Login: github.Ptr("prauthor")}, } // Setup mock check runs for success case @@ -1705,6 +1773,8 @@ func Test_GetPullRequestCheckRuns(t *testing.T) { expectError bool expectedCheckRuns *github.ListCheckRunsResults expectedErrMsg string + lockdownEnabled bool + restPermission string }{ { name: "successful check runs fetch", @@ -1756,6 +1826,39 @@ func Test_GetPullRequestCheckRuns(t *testing.T) { expectError: true, expectedErrMsg: "failed to get check runs", }, + { + name: "lockdown enabled - author lacks push access", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposPullsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, mockPR), + }), + requestArgs: map[string]any{ + "method": "get_check_runs", + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + }, + lockdownEnabled: true, + restPermission: "read", + expectError: true, + expectedErrMsg: "access to pull request is restricted by lockdown mode", + }, + { + name: "lockdown enabled - author has push access", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposPullsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, mockPR), + GetReposCommitsCheckRunsByOwnerByRepoByRef: mockResponse(t, http.StatusOK, mockCheckRuns), + }), + requestArgs: map[string]any{ + "method": "get_check_runs", + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + }, + lockdownEnabled: true, + restPermission: "write", + expectError: false, + expectedCheckRuns: mockCheckRuns, + }, } for _, tc := range tests { @@ -1763,10 +1866,16 @@ func Test_GetPullRequestCheckRuns(t *testing.T) { // Setup client with mock client := mustNewGHClient(t, tc.mockedClient) serverTool := PullRequestRead(translations.NullTranslationHelper) + + var restClient *github.Client + if tc.lockdownEnabled { + restClient = mockRESTPermissionServer(t, tc.restPermission, nil) + } + deps := BaseDeps{ Client: client, - RepoAccessCache: stubRepoAccessCache(nil, 5*time.Minute), - Flags: stubFeatureFlags(map[string]bool{"lockdown-mode": false}), + RepoAccessCache: stubRepoAccessCache(restClient, 5*time.Minute), + Flags: stubFeatureFlags(map[string]bool{"lockdown-mode": tc.lockdownEnabled}), } handler := serverTool.Handler(deps) @@ -2429,6 +2538,33 @@ func Test_GetPullRequestReviews(t *testing.T) { }, lockdownEnabled: true, }, + { + name: "lockdown enabled filters reviews with empty author login", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposPullsReviewsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, []*github.PullRequestReview{ + { + ID: github.Ptr(int64(2040)), + State: github.Ptr("APPROVED"), + Body: github.Ptr("Ghost review"), + User: &github.User{Login: github.Ptr("")}, + }, + { + ID: github.Ptr(int64(2041)), + State: github.Ptr("COMMENTED"), + Body: github.Ptr("Another ghost review"), + }, + }), + }), + requestArgs: map[string]any{ + "method": "get_reviews", + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + }, + expectError: false, + expectedReviews: []*github.PullRequestReview{}, + lockdownEnabled: true, + }, } for _, tc := range tests { @@ -3834,10 +3970,30 @@ index 5d6e7b2..8a4f5c3 100644 + +This is a new section added in the pull request.` + // Under lockdown the diff path first fetches the PR as JSON to resolve the + // author, then the raw diff; branch on the Accept header to serve both. + prOrDiffHandler := func(authorLogin string) http.HandlerFunc { + mockPR := &github.PullRequest{ + Number: github.Ptr(42), + User: &github.User{Login: github.Ptr(authorLogin)}, + } + return func(w http.ResponseWriter, r *http.Request) { + if strings.Contains(r.Header.Get("Accept"), "diff") { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(stubbedDiff)) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(mockPR) + } + } + tests := []struct { name string requestArgs map[string]any mockedClient *http.Client + lockdownEnabled bool + restPermission string expectToolError bool expectedToolErrMsg string }{ @@ -3856,6 +4012,37 @@ index 5d6e7b2..8a4f5c3 100644 }), expectToolError: false, }, + { + name: "lockdown enabled - author lacks push access", + requestArgs: map[string]any{ + "method": "get_diff", + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + }, + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposPullsByOwnerByRepoByPullNumber: prOrDiffHandler("reader"), + }), + lockdownEnabled: true, + restPermission: "read", + expectToolError: true, + expectedToolErrMsg: "access to pull request is restricted by lockdown mode", + }, + { + name: "lockdown enabled - author has push access", + requestArgs: map[string]any{ + "method": "get_diff", + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + }, + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposPullsByOwnerByRepoByPullNumber: prOrDiffHandler("writer"), + }), + lockdownEnabled: true, + restPermission: "write", + expectToolError: false, + }, } for _, tc := range tests { @@ -3865,10 +4052,16 @@ index 5d6e7b2..8a4f5c3 100644 // Setup client with mock client := mustNewGHClient(t, tc.mockedClient) serverTool := PullRequestRead(translations.NullTranslationHelper) + + var restClient *github.Client + if tc.lockdownEnabled { + restClient = mockRESTPermissionServer(t, tc.restPermission, nil) + } + deps := BaseDeps{ Client: client, - RepoAccessCache: stubRepoAccessCache(nil, 5*time.Minute), - Flags: stubFeatureFlags(map[string]bool{"lockdown-mode": false}), + RepoAccessCache: stubRepoAccessCache(restClient, 5*time.Minute), + Flags: stubFeatureFlags(map[string]bool{"lockdown-mode": tc.lockdownEnabled}), } handler := serverTool.Handler(deps) From 8f6aa8e9025283f0ea0c0e2f2ca4faa369276741 Mon Sep 17 00:00:00 2001 From: kerobbi Date: Tue, 14 Jul 2026 13:21:16 +0100 Subject: [PATCH 13/35] fix flaky test --- pkg/github/pullrequests_test.go | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/pkg/github/pullrequests_test.go b/pkg/github/pullrequests_test.go index acf4abdaa5..c48b86bcb6 100644 --- a/pkg/github/pullrequests_test.go +++ b/pkg/github/pullrequests_test.go @@ -1742,7 +1742,6 @@ func Test_GetPullRequestCheckRuns(t *testing.T) { SHA: github.Ptr("abcd1234"), Ref: github.Ptr("feature-branch"), }, - User: &github.User{Login: github.Ptr("prauthor")}, } // Setup mock check runs for success case @@ -1829,7 +1828,11 @@ func Test_GetPullRequestCheckRuns(t *testing.T) { { name: "lockdown enabled - author lacks push access", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - GetReposPullsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, mockPR), + GetReposPullsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, &github.PullRequest{ + Number: github.Ptr(42), + Head: &github.PullRequestBranch{SHA: github.Ptr("abcd1234")}, + User: &github.User{Login: github.Ptr("reader")}, + }), }), requestArgs: map[string]any{ "method": "get_check_runs", @@ -1845,7 +1848,11 @@ func Test_GetPullRequestCheckRuns(t *testing.T) { { name: "lockdown enabled - author has push access", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - GetReposPullsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, mockPR), + GetReposPullsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, &github.PullRequest{ + Number: github.Ptr(42), + Head: &github.PullRequestBranch{SHA: github.Ptr("abcd1234")}, + User: &github.User{Login: github.Ptr("writer")}, + }), GetReposCommitsCheckRunsByOwnerByRepoByRef: mockResponse(t, http.StatusOK, mockCheckRuns), }), requestArgs: map[string]any{ From 334aac0d2b99409c1048ed713ba7b483deabf413 Mon Sep 17 00:00:00 2001 From: kerobbi Date: Thu, 16 Jul 2026 11:39:49 +0100 Subject: [PATCH 14/35] remove get_check_runs gate --- pkg/github/pullrequests.go | 14 ++------- pkg/github/pullrequests_test.go | 53 ++------------------------------- 2 files changed, 4 insertions(+), 63 deletions(-) diff --git a/pkg/github/pullrequests.go b/pkg/github/pullrequests.go index e36096bc9c..daf3b97331 100644 --- a/pkg/github/pullrequests.go +++ b/pkg/github/pullrequests.go @@ -152,7 +152,7 @@ Possible options: result, err := GetIssueComments(ctx, client, deps, owner, repo, pullNumber, pagination) return attachIFC(result), nil, err case "get_check_runs": - result, err := GetPullRequestCheckRuns(ctx, client, deps, owner, repo, pullNumber, pagination) + result, err := GetPullRequestCheckRuns(ctx, client, owner, repo, pullNumber, pagination) return attachIFC(result), nil, err default: return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil @@ -315,7 +315,7 @@ func GetPullRequestStatus(ctx context.Context, client *github.Client, owner, rep return utils.NewToolResultText(string(r)), nil } -func GetPullRequestCheckRuns(ctx context.Context, client *github.Client, deps ToolDependencies, owner, repo string, pullNumber int, pagination PaginationParams) (*mcp.CallToolResult, error) { +func GetPullRequestCheckRuns(ctx context.Context, client *github.Client, owner, repo string, pullNumber int, pagination PaginationParams) (*mcp.CallToolResult, error) { // First get the PR to get the head SHA pr, resp, err := client.PullRequests.Get(ctx, owner, repo, pullNumber) if err != nil { @@ -335,16 +335,6 @@ func GetPullRequestCheckRuns(ctx context.Context, client *github.Client, deps To return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get pull request", resp, body), nil } - if deps.GetFlags(ctx).LockdownMode { - cache, err := deps.GetRepoAccessCache(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get repo access cache: %w", err) - } - if restricted, err := authorLockdownResult(ctx, cache, owner, repo, pr.GetUser().GetLogin(), lockdownPullRequestRestrictedMessage); restricted != nil || err != nil { - return restricted, err - } - } - // Get check runs for the head SHA opts := &github.ListCheckRunsOptions{ ListOptions: github.ListOptions{ diff --git a/pkg/github/pullrequests_test.go b/pkg/github/pullrequests_test.go index c48b86bcb6..ace47c666b 100644 --- a/pkg/github/pullrequests_test.go +++ b/pkg/github/pullrequests_test.go @@ -1772,8 +1772,6 @@ func Test_GetPullRequestCheckRuns(t *testing.T) { expectError bool expectedCheckRuns *github.ListCheckRunsResults expectedErrMsg string - lockdownEnabled bool - restPermission string }{ { name: "successful check runs fetch", @@ -1825,47 +1823,6 @@ func Test_GetPullRequestCheckRuns(t *testing.T) { expectError: true, expectedErrMsg: "failed to get check runs", }, - { - name: "lockdown enabled - author lacks push access", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - GetReposPullsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, &github.PullRequest{ - Number: github.Ptr(42), - Head: &github.PullRequestBranch{SHA: github.Ptr("abcd1234")}, - User: &github.User{Login: github.Ptr("reader")}, - }), - }), - requestArgs: map[string]any{ - "method": "get_check_runs", - "owner": "owner", - "repo": "repo", - "pullNumber": float64(42), - }, - lockdownEnabled: true, - restPermission: "read", - expectError: true, - expectedErrMsg: "access to pull request is restricted by lockdown mode", - }, - { - name: "lockdown enabled - author has push access", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - GetReposPullsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, &github.PullRequest{ - Number: github.Ptr(42), - Head: &github.PullRequestBranch{SHA: github.Ptr("abcd1234")}, - User: &github.User{Login: github.Ptr("writer")}, - }), - GetReposCommitsCheckRunsByOwnerByRepoByRef: mockResponse(t, http.StatusOK, mockCheckRuns), - }), - requestArgs: map[string]any{ - "method": "get_check_runs", - "owner": "owner", - "repo": "repo", - "pullNumber": float64(42), - }, - lockdownEnabled: true, - restPermission: "write", - expectError: false, - expectedCheckRuns: mockCheckRuns, - }, } for _, tc := range tests { @@ -1873,16 +1830,10 @@ func Test_GetPullRequestCheckRuns(t *testing.T) { // Setup client with mock client := mustNewGHClient(t, tc.mockedClient) serverTool := PullRequestRead(translations.NullTranslationHelper) - - var restClient *github.Client - if tc.lockdownEnabled { - restClient = mockRESTPermissionServer(t, tc.restPermission, nil) - } - deps := BaseDeps{ Client: client, - RepoAccessCache: stubRepoAccessCache(restClient, 5*time.Minute), - Flags: stubFeatureFlags(map[string]bool{"lockdown-mode": tc.lockdownEnabled}), + RepoAccessCache: stubRepoAccessCache(nil, 5*time.Minute), + Flags: stubFeatureFlags(map[string]bool{"lockdown-mode": false}), } handler := serverTool.Handler(deps) From 0e9bf0c1b60ae09c555f2daff9bd05aeccaf74c9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 15 Jul 2026 13:41:49 +0000 Subject: [PATCH 15/35] build(deps): bump actions/cache from 5 to 6 Bumps [actions/cache](https://github.com/actions/cache) from 5 to 6. - [Release notes](https://github.com/actions/cache/releases) - [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md) - [Commits](https://github.com/actions/cache/compare/v5...v6) --- updated-dependencies: - dependency-name: actions/cache dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/docker-publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index ddd3c43991..0ddb8acabb 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -87,7 +87,7 @@ jobs: type=raw,value=latest,enable=${{ github.ref_type == 'tag' && startsWith(github.ref, 'refs/tags/v') && !contains(github.ref, '-') }} - name: Go Build Cache for Docker - uses: actions/cache@v5 + uses: actions/cache@v6 with: path: go-build-cache key: ${{ runner.os }}-go-build-cache-${{ hashFiles('**/go.sum') }} From 4307ff801bbb18ed4dd16b2f31c6c6ff4760464c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 15 Jul 2026 13:42:21 +0000 Subject: [PATCH 16/35] build(deps): bump node from `a2dc166` to `e88a35b` Bumps node from `a2dc166` to `e88a35b`. --- updated-dependencies: - dependency-name: node dependency-version: 26-alpine dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index acf38a77dd..0568f827e5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM node:26-alpine@sha256:a2dc166a387cc6ca1e62d0c8e265e49ca985d6e60abc9fe6e6c3d6ce8e63f606 AS ui-build +FROM node:26-alpine@sha256:e88a35be04478413b7c71c455cd9865de9b9360e1f43456be5951032d7ac1a66 AS ui-build WORKDIR /app COPY ui/package*.json ./ui/ RUN cd ui && npm ci From 225ba207c888bad65673380baf30b58bf692105a Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Thu, 16 Jul 2026 15:01:39 +0200 Subject: [PATCH 17/35] perf(octicons): embed precomputed data URIs Move Octicon base64 encoding to the generation script and embed the generated lookup manifest for zero-allocation reads. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5557f448-5cc1-46ce-b5ad-36f9e2e224e0 --- pkg/octicons/icons_data_uris.txt | 62 +++++++++++++++++++++++++ pkg/octicons/octicons.go | 45 +++++++++++++----- pkg/octicons/octicons_benchmark_test.go | 39 ++++++++++++++++ pkg/octicons/octicons_test.go | 44 ++++++++++++++++++ script/fetch-icons | 18 +++++-- 5 files changed, 193 insertions(+), 15 deletions(-) create mode 100644 pkg/octicons/icons_data_uris.txt create mode 100644 pkg/octicons/octicons_benchmark_test.go diff --git a/pkg/octicons/icons_data_uris.txt b/pkg/octicons/icons_data_uris.txt new file mode 100644 index 0000000000..1083af68b8 --- /dev/null +++ b/pkg/octicons/icons_data_uris.txt @@ -0,0 +1,62 @@ +apps-dark data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAA3ElEQVRIie2TTQ7BUBSFvydSTCzAHpgYsQAbENaCHdibkbAEG/AkfiI5Bi6p11daMZH0Tl5z7tf23N4e+PdyoSCpDfSApkknYO2c23/DhQ+fSvLKlpc0Kcu9TGCOdsAKWAJXa9WBGdAHOnbPR84550P3Q3Mxikw2st6gKPfQ6ql+Yucl8vUeWiOiveVqEeinlZ7gbGcS4ZKAKcylX7AFDsBCUo3X5c0BD2y4L7kIly1Jk5zfby9pXJaD/KB1gZZJR+4B8t9weUt2wXXGSEnu6apKcpXkKsn8Psn/XzcGZLHb6HPXrwAAAABJRU5ErkJggg== +apps-light data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABQklEQVRIie2TT0rDYBBH3yRf/yildO3WLKTWVnDRk+heT6FeQDyEXkA8gF5ABBcSrFisSy9gi01K840LE00axVa6KfS3mgwP3hdmBhY9MtnwvHbVloKmsU4ZYOzYwAnLfq93+/YfLiPw6q09lDOgMuEdIHLQe7y/mIXLCDyvXaUQvAJ36uipa90xQORERiyHIDuuDdaiqCbTcN1utw9gEoGasCFQQTl56fjXmT/baFmEq0hWG2pCmYYDbjICgWJcjchFRqCAliTT+51LOk4emm/MdylhbC/msaQnYfw9JZcWjIsdCsEA0eP1zaaTHh5WjkD7rr4/xEP+k/t6dtrv1bd3UT0nv359Ud1/fvIvZ+FyAvhcV3XDLdeRFYDI6tAw9JO1m5X7cchqVNJ1FNVyD5mWW17y8pKTLC85LZnzJS9+PgCdlmVVW+jjvwAAAABJRU5ErkJggg== +beaker-dark data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABPElEQVRIid2UzypFURTGv+sacMmIeAIk5srNQAw9gBTmHuAS6kw8jCLKA5AuMbtP4EQMyZ/uhPiZrJPTaZ+7zz4mul+d9m6v78/atfaRuhbADNDGjzYwnefT2yHjVtK+fZeSmpl6XdKc1eO/3AQgcpxHAD59T+nkgujyAKDqM/BxfDdYs7XlqLUynDAA/cA9cA1UcjhN4BEYKBOwayNa78CZBb6BnVDzEeAVOCzAPQLegTFXPe8lR5KGJD0DDU/Gk6RBSXuSNn0NCZgAPgr8g7L4BKaKBJwAb8Col/yrGQZegGMfcd662SpqntJum3Yhj1ABroAHoFYioA+4A26cYw0sWQfroeYpjw3zWEzO0i950tazsgEp7XhykB7Tc0lfkg6AU9uHoCpp2XQXTgawCsQlRjRBDKwENvbP8QN5z38MSMfuOQAAAABJRU5ErkJggg== +beaker-light data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAACA0lEQVRIid2TzWsTURTFf/dNIvbLlS6EFtRO0s5IOimCEhQXKu5EBV0J/gNuBAuCImZh1K4E/wlx4cqli4pUcBO0sR8aow2CRUFdCA0BZ951Y0JsOvnoSj3Lc+4955158+Bfh8QJ+yYzGSPmBTDYxaOm6KH3K6XFzcRE3NbwNvlQC7WASgHhOcr8hqMdQTmMaGEowWqXQ8TD9QJ1/Wy+jfezedcLtNu+2XJyj/jvA8473S06z3QMSPnliwCqvNqoNbjGTBxi38HoaG5g+0jtLbBWWVnIAW1/jOsH8yh7B5OaLpVK6301GBipXQHGrGFmM/PfNWaA3es/zeW+Grju9C6StoLwpLK8cC5uGWDcCx4JnAxNmKouLX3eqG/+kpNRHmQHqt9dL7jaKQD0G8hwwjo3gEtdG0xMTE1ERl4Dyc7GbQgxJqgsvVxuJdsaRIZZoB450djq4uKXXpzT6QM7rRNWsLYAnG3V/rjklJc5CnIa5Xav5gDlcvErqrPAmfH9U8fiAkSRu8Cn2lDifq/mDYT1H/dAP4qVO7R8+mZAysueAMmpyvW1YrHWb0C1Wq2LmpvAwbQ3fbwtwIpOAqjjzPVr3mwhMgdg0XSDa16yWvtUxETGhg9df+oxKlFf7qIOak8BkWKfNenWGdfPXlDVWwJ7ttJAoWpUr717U3qwlf2/E78ApOqvsKFCaCkAAAAASUVORK5CYII= +bell-dark data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABYklEQVRIid2Vu0oDURRF9zWxVhOwikIsDSn0G/yBIIKdNvoRBvMDKvj4goiF6bTRgIi9hZ2PztiJhfgKQTQuC09kiBPnkYDghsvMnLPPXnMZZkb6awE5YAu4AOq2zoFNYLyb4CSwBjSBZ2APWLG1D7xYbxVIxAHs8KV1YNCnPwRsmGc7aviMDS6F8C6bdzoK4AS4BPpCeBPAFXDs1+8UMCGp6pz7CAI455qSqpImowAGJD0GhXv0IOnHc/oN0DP9QwCQtNO3CDnvbbOdAZLydryOAKjZMRfoBCrAE5AKmw6k7HNSCTIW7K0shg33zBZtttDJkAZugTOgPwYgCZwCd8Cwn6EMvAJ5n/mwkLxllFs152neS7qRtBsXYJqVNOqcS7fTS/Yz6VZ1oBRl24s2mPHURqy20OVuJWAMaAAHQMbCD62W7RpgkHkLbKkBzIWZdcGWb0hW0pRdHjnnajHutff6BHkunBVaEwyhAAAAAElFTkSuQmCC +bell-light data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAACN0lEQVRIid2Vz0tUURTHP+e9MQcX6RSaQRYxz3zPH+9N/QExUCupRUiroIJIRKiNiyIIpCAyIWplucp1+7BScG+iGY1jGc1CoppIpaiBmXmnRU6MjPqeoJs+y3PP93y4XO69sMNIUIPV6rUBPaieADm0Ws6IMq6m8Wjh7XRqs7y50UIymYxEamoHUUaANuCVqPFckEkQQehC9eqehsbd37PHxyGl682JbCRY/Lz0BOGcIg+L1fRnZl4vl68f7OiIVeWNfoE+y3nXsDDH+c12sgarNXHWcjy1Wt0bQb3NjnfTcjxttt2u9daNdVOqvQjphdTs3SDB+7kjdxTmFekNL4CjqI4CfpAAnhZBRhGObUVQixgrwcP/IsIyULcVwbbxHwqSyWQEQJV82CGiWijPbipY/LrUAWAoH8MKVMgAfMqutAUKUK4DP3aZ+dGwgmopPAN++r5fcTHXvEVxxzsjcEuR2/OpNy/CCrLZ7O9Y/X5D4EqsvnF26duXdMUObNveKzAETNfVmPfCDi/RtK9uQJRJgcdx122oEBQkeh+I+epfmJqaCn3AJSYmJgpF/EtAreSNwVK97NT1NJAyxOi0HK9zq4IyUqCnKgXKA4RrQOADF8AvlIHQ3XE70W05nlqWe+Bfrb29yXI8jduJy0H5DT+cEqZfHPNNI0eVMWxZbrdGfZGCOYyQi6g/FpQP/JMBmu3ERRUdAqKrpZyq9HxIz4xsiwCgpcU9XBDjJEAVvEynZzJhszvKH3dYsqySRWTxAAAAAElFTkSuQmCC +book-dark data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAA80lEQVRIie3VMUpDQRSF4W9MELMTqzSWCroKC0HBJcQVuAB7O3t3oaCFhY0QsHETWkSNciwygVjkvSciWOSHae7cM+fOFGdY0UaScZqZJLlLcrCgOay1SYt2XJIEt7hZMsMAexjiDD2M8IArTJbodrCtOp223LKX5HxhsvMkvRbNaZKsNTXNKaV84gQveMao1lrpd2mqJpMkY6SU8tpV19mg8ob8RNDpiX7DymBlsDKgJPkwS8hlvOMRF9ivtUscYxMbtfZhllVzBlgvNeeHDQZ9bJlF9iLXuMe0QfvUsPed+slM6zrqLPwJSXaT7P7J4f+WL0KGt77U8oz2AAAAAElFTkSuQmCC +book-light data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABQElEQVRIie2Vv0pCYRjGf29KZLfREdTBPzQa6E3UEDREq4vRBXgBLUFDk0NDEN5CQ0HRFIhgSik4eAPloJb6NGik4DmdoqHh/LaP73m+53mX94OAbzAnnqoDCQ/NAKiZ7OS5Wb0AcOKpPaAAJIE1D++jOfGUMO4Qt8sUEpEVs7xQ0mTHMoWAomG1iXRtRn95dbYQ2fDsdNVqVEvuRbZDG/GnU0xHAIKzViNagMrYzeEk0iVQdsVjvDkq42Fv/RDoAa/j/kvR6/F5fAZAt3vfB+qgeqfTGfj1hf0Kp2gIpp84fE/wW4KAICAImK7rERDy0LwZ1oRJWdgOgJkuJdsHYnyt6xHTXfVJBFi1aCy9K1PSvQJhxCaQX7zQDWYPiHc3q2Rt82i+wOyTKQMIHbQbtXO/Xt84sUzOiWVyf/7wv+YDLEBldFDwbfoAAAAASUVORK5CYII= +check-circle-dark data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAACA0lEQVRIibWVO09VURBG1xALaFRoDFwSjY1ohESotMJoY/BBa+XjFxgLQvQHGG2Inb9AKxDs1U40xNKoMRFUFK24t9HYsCwY9HA53AeGnZzi7Plmzcw+s+fALq9oZFR7gHHgIjAA9KdpGXgLPAFmI2K1rQBqF3ATmAD2AovAPPAjJQeAU8AhoAbcA6Yi4lfTktR+9bW6pk6rww20w+qM62tBrbQCX1ar6rmm2fzzG0ufL9sGUbsy86p6vFV4wX8wfRfUzjLB7TyWljMvYZzP45qsN/SoNXV6p/AC63FW0l3cvJ6RT7QJG1Dfq+OFvZFkXS0KZ9WPO4Cv5HO4zrakzgB05N5R4GUb8CPAU9bv0dmIqE9uHjhWDNAHfK2D9KqXyzIHnqfvaES8KcnhG1ApBoCtt3oUeKhO1cGfpfZ0RLzbpsgA1gD2FCL2bVJEPFKHgEkV4AHrx9IBnGkAJ1krf9+ytRbLlOpUdsXP/KADDcAbPkubWl69lpDSuaPeVT+0CN9o0yvFzZ68HDPNAC0EmFVX1f31hls5Ksb+A34hs58oM3bmoKqqgzuAD+W4eVU67FJUyZFba6eSzLymflb7mokrWYnZXSMNtCN55mbmW+Db/TI7gRvAJLAP+AS8AL6npBc4CRwEqsAd4H5E/G4pQCFQN3Apn7Kf/hwwFxHVRpxdXX8A4YvY5L3k2CoAAAAASUVORK5CYII= +check-circle-light data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAADS0lEQVRIicWVwXNTVRTGf+e+aMNo25GNA2Sk0Nc2j5jXQllQR2dgdMfQprrqjH9EZSE4g7s6zHQh6so/QHRJ040bkQWMZRixJLFJG1IamFJ1I21xSGPy3nHRJL6WtDQLx2957jnfd75z330H/mPIboexWGx/WUMJVIcViQpEABSWBXIiTJctf+pRJvOkJYFIZGhfW3vpvKAfAx3AEqIzqPyxWaWvq8pbAl3AGqKTG+uvXFlenim9UMC23QgvSRI4rnBN0M8KufQvzRqxHfeEIJcURkF/Np4k8vnU4x0FauS3gVdV/LHFbOb7nawH0eMMnFX0KspT43MqKNIQiESG9oXbn90CuhV9ezGX/nUv5HUcjcbjRsxN0PvV0vo7xWJxA8DUE9raS+eB4yr+WKvkAA/mMxlRPgQ5GQp3jm9xEIvF9pf90JLCD4u51AetkgdhO/3XgDN/h/wjjzKZJwagrKEE0CHCRCtkvb3xqO30L3Q7A4l6TNWfADrbKtYI1EekOgwsFbKp2VbIfcvcADosz0vX44vzmbvAQ0SH/xVAHEVu75W8r8/t8y1zHRCE9/L5zIPgucCMD8cCAhw0ypbv13EGD/RE3bFmnVeN3ACM8fzThWxqbnuOCisCh4ICqNEtb6KqldMq8q3tuFeC5J5lfhQQ4/ln8vnMfFOLigA+QKgWWlGVg8Gc+/Pp7+xj/S4qF23HxfL52jNyXcAgvLsjeW0iCr8FHWRBh7ZnFbKpT0C/ABn3jMzuNpZtOAXMNQREmBbosh33xHMiufRHCpPA413HUkN3ND4IHEZlGmojelmqybKG1gS5BLy/vWgxl7oAXHhB12w2K58Cq16YZMPB3Nzcn4hOKiR6nIGzeyFqBjvqngMZEeRy8d691YYAQPXZ+uegdxW9ejQaj7dKfqTvTReRb4A7ldLqV/V4Q6BYLG4YTxIoT42YW604saPuOctYN4E1yzej9T8pNFk4vb39h3xLp0BOAlOq/kTt+T+H7mh8cHPmMgLcsXwzurAwuxLMaboyu7q6wqFw5zjCRaATeKjIT4L+Xis6oDAEHAZWBbmslb++LBQK5e1cuy79N+Lx19oq1oiKjiBE0c2lj7AsSs5XSXphkvUL/V/wD2PSUWQSc/XIAAAAAElFTkSuQmCC +codescan-dark data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAB5klEQVRIibWUPU8UURSGn2us6ECtgERC3FI+aksbsLDytwja+YEbNWJp/AsGNLQWYmHQsBS22JgYooUKlLiGx2LOJOM4d3ck4SSTzb3nfc979nzcRMbUWeAGcBWYBM4D34EvwGvgRUrpY45fWmoI3AEeAteBPvAO2AX2gVGgA1wBzgIvgZsppU/DhMrg19RDdV9dUkczuFF1WT2Ib7Ft8N/qtjreMqFxtRe8hUHATmS+rY60CV7hjoTIgTqdA72KsrTKvIE/EQmuNTnnLGwpQ56qnTsZ3G31WJ2pO+6rv5oaqq6G+FQZPM6rDdgxta/eqTs+qG8aCN2mYOqD8l5NNd9bdase6Jv6rE3wYSLqc3WvDj5SVyrnsgw79QwrmBR+qz1RV9SjKvYM8AM4V16klHaBLjAPPMmIrIT/aeBLuwD8rGeT60GuDNny5XpwN7rfZormBgQvp+he3TEbpOWGUvy1B1H7+Qyu3IPLTc51i1WfaCIPM3XSYpM3coBLIdDzZG/RTvAvDgIuWryKvbb/JDIvx3W9DWEhMjlUb6ljGdxY1Pww8OuDFrNOnlbXomF9dTM2tBu/m3F/rG6UZalM3HCRIMzECG+pexYP4lf1vcUD+c+0/LfISawi8vg0RR6pn09NoGp/AOhxr9ifbi9WAAAAAElFTkSuQmCC +codescan-light data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAADYklEQVRIibWUXU8cZRTHf2cGmmYvjLsCjdSi0tmFZWFnYe+0iYnBK6OFWG/6AUyNSUN7g/oBDEUj3Jl+AE3UqG1ibNTUxBq8aWh3l5d9YbpFWxsbKNytpss8x4vdxd0BhCb2XM085+V3zn/OPMIeFk2kUurzJqKjCscEOhTWBe4I+qNY9pelpZvZvfIbJsGD3n43ZolOg5wEqsCcCCVVNkUIqxIDTgBtAt/4yGQ5n1k5ECAaT72q6GeAQbnwsN1c/H1hYTOY1DM0FD5UlTOITAIY5HQ5n/nuPwH14pdBb1q+jJVK2T/2Gz8Wc48aWy6DplTMa7eWF67sCqjJwnXQUiXU/tK9+fnKfsUb1p1Oh0IV/xqog+2nvcXFW81+C6CmOcbyZexRigPcm5+vUDVjgKhvTwf9ljPgDoOcRLmwmyx9fcnnm997+91YMMbzcncVmRYYjyWG3dYJjJwCqg/bzcVgohNPzviWlBuQupRFJ56cCcYetqqfAL5vzButANFRYC64LU7cnQKZAJ0tFnO3AcqFbAlhCmSiDtlekqWlpQ2QXwVeaQXAs6oUdxZnEnTWy+fOtcixnH2vCfIxLatuikBPEBAWSzYaB3WNJ4EbXj53PihFHfI+cANkorffjW47xFoHOloACg8Efapx8K8MjOzssD7hgPsBMAI6Wy5kS9sOpRPYaI61BO7Uf/8DyeDE3SmUd3eTD4gBq8EJfgBO9AwNhXdA0FmQib6+5HP1zofZ49skEokI6AuCXm0BWJZ8BbQdqsqZoBRePnfONtrb2CJvOZsBTe/SOX+b9rcBe8uYL5rPBSAad79WeJmqDnpe7m4weT87Pjh4THx7EeFnbzn7essEAD61W5F261J3Oh16lOLd6XRIfPsSoG1Gzgb9FkA5n1kxyGnQVKjiX3Oc5DMH7TxU2foFGBH4qVDIrAZj7MbD5vqfK+GuruuCvIUt70S6jlhPd3Usra2t/RVMSiQSkSc6jp4XlU+BsMAVhfFI55EnN9bvf98cu3PHBwePq29PC4wDPugcUALZAI0AMZAXARvh2zYjZwuFzGrt6qhdLc1LsAPQsFhi2PWNOSUwSu337wQeAL+hctXXrc9vFxdzLc3tAflfzYknZ5y4q86A+9FjAQBE+1MfOnF39bEBmu0fDu1ngmt0ncYAAAAASUVORK5CYII= +code-square-dark data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABMklEQVRIieWVu0oDURRF1xGx1sZSUBGtUtkoIokQMJ2NfoH6HfmeVHYRBBOCaGNlJfgCSxsTG7FaFrmBIT6SMCmC7m72OWeve2a4DPwZqcdqx/zqqEe93MgAOsAt0Mh51hKwGhGz/RuoVnOGo1ZVe89TeQMH6R8A1IJ6r+5nvIPkFXIB1E2gCcwA15nSdfKaqWd0gLoDnAKvQCkinnq1iHgEtoAX4EytjARQt4E6cAdsRMRDf09EPAPbdO/OSZoZfoNx6VtARLSACrACXKnL/T3qAtAC1oC9NDMcIEHOgV1gDmioi5nwJeACmAfKEVEfaYMM5BIoAh/Aeqa0nrxi6vlR078VE+SG7qvKejWgNmgWJuEmTzwg+w3egLL6njOzDHS+uOqR2h7DL7OtHuY85ATpEwmNCUIGG3dAAAAAAElFTkSuQmCC +code-square-light data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABtElEQVRIieWVzWoTURiGn29m7LLUjUshnUknk+ZnwCK0SGiFgN250UvwCnIB3XsJ3kFX7iIItgSpmwppkiaTZtJCl24k3UgxM58bp8QqMXFcRHxX53w/78PH4ZwD/7okWdg5/4WIvgSWU3peqUptGDRf/QBwvPIICICDlIAdwA17JysA1kRiGZF62G3upXF38v4eqg+TvZHGbBb9B4CMWyg5nh86ef9ZEnPy5eeO54cZt1BKBbBz/pZpmIegS2YUH980jeNj0CXTMA/tnL/1RwB7vfRYRN8gfMaMdvr91kWSOztrn8eG9Ujhk4i+tfPF3bkAWa9YkVjqwEAt3Qw7neHtmvPTj5fc0QoQiBqvs16xMtcEf0u/BAx67YYaugtkZSwfnELBvl2zuv7gPl+lAeRU4qeDXrsxMwBgeNp6pypPUO4SmQeuW8okubW14qoRj98L3FOV6rDbrs81wQ0kaB5FcbQNXEemsZHEY8vYAK6jONoeBs2jaR7WtCTARb/TArKTsbB7sg/s/64XFuEmLzxg8gyuUK06XvlLKkfVKjD6CaAqte9f5tS3ZQaNQGspPRZI3wDLF5GVH3ZwBAAAAABJRU5ErkJggg== +comment-discussion-dark data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABHElEQVRIie2UoU4DQRRFzyPF48HjkPiK/gGGBEnx+IpSQ5OK/gQpn4FFo/gAqEJ0MXUcBLtks3TZnXQrSLhqZvLm3jfz7nuwa6hXamY6MnXYxB9qBjwDD4m59YHjiDhoeoHqOJEcdazaFLeXSpyKf4FvqE+bXNbrSgCYAielfR+YdSYQEQtgUexzZ57+nRrUoQe8AwN1DXwA9xHxAqBeAIc1dwdA1qigDtVVqfqj/HzeMItW6uUvvD87vRgb6jRfz1v8QpLAWl1uS14WqBZ5BuwDk4i4LgU/qmfbCDZlkzxxixe0bbSy06p4jYi7nPQIOOfL/u1cll+sOq2KeR43auuy1lBvC5GNzukCpZ5Z1nxjJyI36ps62YlAHT4BQQp0k6TPqNYAAAAASUVORK5CYII= +comment-discussion-light data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABnUlEQVRIid2Uv2sUURSFvztk1TRBS9EuD5LZxJmAoGCXIv+BjZAiVeosDGiVXwoKWyS9rcTG3tLWcmeKzIIvXcR6tlEI5KTZDZPsspvJjoWe7r177/l43MODvyybX1zZNFMbmKs425MsOel2Po4FuDAugC7wrSJgFVjweXp/XNMMMIfZV3/c2a3i7poru0jPJvUFVUxvo38fMFOXkQvjDHhSuupJltQGMNkHmaLS1aqZ2rUBfnQ7R8DR4DxI2f+x5B7Smgvj34ad6+z8s/fZKYBrxuuIRyMnpTWgmAiQLOn/RS+EsIbdBd65MDpAbI2ZLUDJJICVDy6Mhdke0j3gNejQ51lrksko9Ze8cz1FfyRtGjycxrysK0sWtA0aGPtlcxfG391S9PI2gCsvOMnTbWB7RN9zFCwDX6YCjNFl0oYqxk9/nH4CcC56bI3glVAwSNmNAOWkDRfBhdFTn2cta9iG0Nt+pQAlNjRQUa4Zv0e8AR1iQYG04/P00ndqAIALowOwLcEvgwc+T2cHtVq+Cp9nLYx9Q3cE7To8b6wLDK+aTnM31aEAAAAASUVORK5CYII= +copilot-dark data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAACCElEQVRIid2UPWsUYRSFn3dxWWJUkESiBgslFokfhehGiGClBBQx4h9IGlEh2ijYxh+gxEL/hIWwhYpF8KNZsFRJYdJEiUbjCkqisj4W+y6Mk5nd1U4PDMOce+45L3fmDvzXUDeo59WK+kb9rn5TF9R76jm1+2/NJ9QPtseSOv4nxrvVmQ6M05hRB9qZ98ZR1NRralntitdEwmw8wQ9HbS329rQKuKLW1XJO/aX6IqdWjr1Xk/y6lG4vMBdCqOacoZZ3uBBCVZ0HDrcK2AYs5ZkAuwBb1N8Dm5JEISXoAnqzOtU9QB+wVR3KCdgClDIr6kCc4c/0O1BLNnahiYpaSmmGY62e/JpCLJ4FpmmMaBHYCDwC5mmMZBQYBC7HnhvAK+B+fN4JHAM+R4+3wGQI4S7qaExtol+9o86pq+oX9Yk6ljjtGfVprK2qr9Xb6vaET109jjqb3Jac2XaM1PLNpok1Aep+G/+dfa24nADTX1EWTgOngLE2XCYKQL0DTfKex2WhXgCutxG9i/fFNlwWpgBQL6orcWyTaldToRbUA2pow61XL0WPFfXCb1HqkPowCj6q0+qIWsw7nlpUj6i31OXY+0AdbGpCRtNRGgt1AigCX4EqsJAYTR+wAzgEdAM/gApwM4TwOOm3JiARtBk4CYwAB4F+oIfGZi/HwOfAM6ASQviU5/Vv4xcBzmW2eT1nrQAAAABJRU5ErkJggg== +copilot-light data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAC20lEQVRIidWUS4wMURSGv3O7kWmPEMRrSMzcbl1dpqtmGuOxsCKECCKxEBusSJhIWEhsWLFAbC1sWFiISBARCyQ2kzSZGaMxHokgXvGIiMH0PRZjpJqqHpb+TeX+59z//H/q5sD/DqlX9H1/zFeX2qzIKoFWYDKgwBtUymL0UkNaT3V3d3/+5wG2EGxB9TDIxGFMvhVhb9/drpN/NaDJC7MGdwJk6TDCv0Gvq0lve9R762GUNdFDLleaZNBrICGq+4yhvf9TJtP/KZNB2PrLlbBliBfRhajuAwnFVa/n8/nkxFkv3GO9oJrzgwVxdesV71ov6I2r5fxggfWCatYL9yYmUJgLPH7Q29WZ4OED6Me4wuAdeQK6MMqna9t0GuibBHFAmgZ9JMG9BhkXZWoSCDSATIq7aguBD0wBplq/tZBgYDIwKnZAs99mFRYD9vd/YK0dpcqhobM6d9haWyOULRTbAauwuNlvsxHTYP3iBnVyXGAa8BIYC3oVeAKioCtAPEE7FCOgR0ErIJdBBZgNskzh40+NF6K6s+9e91lp9osrxMnFoTSmSmPVsF+E5cB0YEDgtoMjjypd5wCy+WC9GnajhEAa4bkqV9LOHKwa9/yneYeyUqwX3AdyQ5EeVrrqro/hYL0g+ggemKh4HGbPmVu0+fB8U76lpR6XgJwZpoGUpNYiusZg1tXjkmCAav0OMTXfJC4eVYPqwbot6l4BCPqyLhd7lwMAWC/cYb3gi/UCzRaKOxsbFzVEM1iv2Ebt5v2Dm14qZbJecZf1Ah3UCrcTbbB+awHnjgHLgHeinHYqZ8aPSXWWy+XvcQZLpdKI9/0D7UbZiLIJmABckVSqo+/OrUrNgF+D8q1LEdcBrAJGAJ8ROlGeicorABWdAswE5gOjge8CF8Ad66v03IjqJb75WS0tE0YOmNWqLBGReaAzgIkMLrt3oM9UpSzCzW9pd+FpT8/7JK3/Gz8Ao5X6wtwP7N4AAAAASUVORK5CYII= +dependabot-dark data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABDklEQVRIic2VvU3DUBRGz0ssFmACKiggghQ0MAUFIl22oKFASsMm2YMJTCwkqJiABSLZh4JEPMD4D0fJqayrd7/vs659H+wC6qNfPLfpDQ0NroBz4BK4CCE06muNeq/apmewkSRlqGP1SS3sTqGm6nitGyKDFNgH5kDRMecAmADvIYTTn2+Qq7OOwrHOTM1jx/g5/93SmjzW3fiQt2+gZupNSX2iLur6E/UaOKg4cwwcldQPgZOKYLfAWwLcVR38Bw/AIgkhjFaOrVZAHet91WTIGfBSUn8F6mfQIMnoj/qcz7++ku1/pn0aFMCwB80h0bKMZ5ABU3UJLDuK7wHTldZ3VvdB2tN9cNYx4A7yAXlQ+a2WEB0PAAAAAElFTkSuQmCC +dependabot-light data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABVklEQVRIic2VwUoCYRSFv/snPUBvEI7kWM5I2+o1InfmSwRRUJto0aInsKXv0aJl4AiNkdFzJDSnRSpmpuOY2Nndy73nHDg/94clw9IM5f3w3uCgXz5149Z2WoFcmiEnbmU8YOwj9tKSzw2vVLnw/FDz7LhlmRlgmIHnB7uGuxMqkzKbCRAiwlTvxtEjjGYgawhtyHRjWJKR3RlWRdYAKt8FjLLg6jWOzjO6ByDvh+8Gp4N6NANnZh+LkAP0OYa8Sw959QKeH7bzfnA03i8Ug6rnh9Gs/ZxXCg8Rm1Nmdsxccbwp57aQylOMnWC85RBnwK+DC+DaZFGuG7eCvuJcJ2AWunHLIF3IbSnpjDctSZ4Nm51BCifBpP5LJ2oCzVn7q3+mfymQSFpblLDPMTyWI9eUtkGt4Ic9QS8LucG6RA1o/xQw1b9ONpdk/A8EAiIcx1n2/yc+AUFHaVcALvEAAAAAAElFTkSuQmCC +file-dark data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAA7UlEQVRIie2VO07DQBBA36BUUHIAjkAVGjgAv1Cky218B06AqGkRFQVFKiS4AjhlJApQEALp0TjIsoxtNogC+bUzO29HM9qFv0Q9Umd250EdNdWMiiAHnoDLlrvsAbvALbANTCLioksHqlmHvKzIXVev1Q91Upe71mptICIWwDFwA5zXSVYSlCQnwBQ4q84kVfAGoG4WkhfgELgHTn9DcAW8A/PlOgHPwBDYKicOUqpHxJ26A4yAjVJouV31dN2ihvNZ0c0XKw+5jV7QC3rBfxBUH7sZMFZfE+uNgfzbqHqg5j/49Ks8qvuJl0vjE3o+2g5KQNawAAAAAElFTkSuQmCC +file-light data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABPklEQVRIie3UMUvDUBDA8f+FQrUdHV1TSCo0mVwUP4Bah24dXDv5HWw3dyfdxMHJSRFnByeLsZBSaCdTnAQXaSmEnJOlLdiURBfpbe+9u/c7uMeDPw6ZXBRsd0/RM2B9sXJ9RTnqdVo3CwGm5QQIH8BdTFvbKFugTyCOqFa7ndZ1PGA7ikij1/bq8+43i24d1eNBLpPPDcNblB1RPex2WlezucbcTmPirdkcDFYz+wgPKnJZsErVXwW+kayEBwqPKnJhWqVyeiCKRgCWZa0B+L7/uWKEu6J4iJxOpmYSAYbcozRCyb6btgPAKGJmoimAXvvlubDhbhJpWSE/Phi/rpQAQNf3PMCb3DOLbh10Ckg95LhYAktgCfwHYPovUvqgFdN2holuU60AwY+AGlFNIuMc4SQRAIEaWktYmyy+AN/Aakjj/WLSAAAAAElFTkSuQmCC +git-branch-dark data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABkUlEQVRIicWVvU4CQRSFz5DYiWKhttJiYgEV6iOYWPsKUmh8AnkCsZQ3sFJjYgI2FiZ2xsqfRDRWxl9itLDis/CKI+5OQCCeZnbPnnvPnZ25M1Kf4VoJICdpVtK9pF3n3FsoAZCUNCdpVNKhc+44JC4CDb5xBaQD+rRpvtAAVuPEORNsAMPANPAIbAcMdkyTB1JA2XJko8TLVkXK40pAPWBQB9a89xHLsfTFJTz9nY0Zj5v0+CjctegzHv+rmkH7n4/AOrBv1SwGZlAwTdVinoAaMBgXkAa2gXfgBlgEfu00T+/MpAa8AlvARGDGzUBid0O0vgg0or4losg/YErSbc8NgCFgRdK8pHIngQB7wEVL48VhExiIyhW5gAD2WJF0EqjlRdKRc+6g7eq9GVx2FBSD0Brs9dvguRcGP2CNtuM1WiHUaJ0mTwLXdlSUgIqtRaFXBguWMO9xVeC8m7z+GozZeOZxp5LGuzFoAshaU5XtXJ+x0zH2wvmLyWpL59baOh0DiLr0s/q89B/UxqX/7/gAj6/Ekn4d+MEAAAAASUVORK5CYII= +git-branch-light data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAACjUlEQVRIicWVTUhUURTHf+e+5weEVsokswnFJ+O8cCYMCkxrUYsioW1E7dyF0E4IDHXRok1BRYsiiGrTJtAIEqIiySDTwdBRGLVd6sig0MKPmXda2OiQ88Sv6KzOO+d/z++ee967D/6xyd+B6tq6Y0akEWW20Mr0jIyM/NqsQCgUKvGkqBlDQJW+RHx40BfguNFOlPac+JTl6Znx8eGp/MUjVRkj74CqPyFFpCsxGuvIakzuzlHaRXjkLdkHVOUkaGnGcMdv9xkxd0FLUdOQLpKDoI9RvemEI/UbACJWEyArhdI2OfltYWIs9lkxz0FO+56P6CngWWJsqP9HLDa/bGsbIAJNGwHqzQBYi7jrFbwjCjN+9VdzsqYvWLZcAC9njZ11Cq1Mz5JnT4l43dW10ReIuoKcBbnm2wByD/S+E472qhIX0SsIk8WSeZ3VWFknmUwuB8orulVwDXJJxRQLciMRjz30A6TmpgfKAsEkygUjNALvbZXL8fj3Wb81ADjhqDru0Y5NRbl6N9rphKNevpzJF9y2KRHgZ76UnS+4VXOcE6XYiy3ARVQ7twVQ5bgTjo4DNeT54ldtMeu83L+v4Na2AIKeR3iL8spPg+oCmP7EWOyDn8T/iEQmEqOxc775LZrvkBV5s9vimwIETe0FwMp9CIUiVQcDwacClYrWlAeCK6m56YE9AYRCoZK0Kfgioo6KeSKrb05rWSCYTM1Nf90pYG3InhQ1C1SiVsPE2FA/gBOO9iraCjzYKWB9BsIhgHSxxrMhRUYFKnZaHHI6UPgEqL3k3T5cV9dWsGy5gl4F/bgbwFoHifjwICJdIC2FaZMSo30I87aa67sBbLgCnHCkXoRGPJJb+en/d/sN+mTh5OWORQYAAAAASUVORK5CYII= +git-commit-dark data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAA+UlEQVRIie2TPU4CURSFzzNS4AZIdAMkZKZzF4ae3mjojKuwIbQm1oSwCWI3QKRgYsMO6BASxWo+mmvEYXxMHAqL+ZJbvPtzzs1NnlRSUhSXtxE4k9Sw56tzbnOUDYAq0AU++OYd6ADVouIV4NlEe0DLome5IXBaxODehG4yardWuytiEAMjT30MzHwaJymxH0gKJEWe+UhSmJ4D4q+G3fs9SApTAm1JNY9BTdJK0mMqH2f07gMMgCVwnlG7AN6Afi6xXwwC4BN4Aeo7+TowBTZAw6eRx6QJrIEEmFskwAq4OjSf6yfbia4lXVpqIunJObf4++olJf+GLdXO7LokfYRNAAAAAElFTkSuQmCC +git-commit-light data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABdUlEQVRIie2TP0xaURTGf+dRUyB1cKmJsvXy50ng0rw4OLkxNd2ta1PSqY1zR2IcO5q4GpOuxsShg2xWFwOWUEIHB9K40qaUVHnHhbQUwoNUBwd+0835zvd99w4Xpky5LTLp4oLnRcOdbhrgdyvyudk8/nUnBbHYSiQ8+3MTpABEeuM2wnbne/TduKLAAs/zZlrtq48gq4juic8BgDo8Q+UFwlHs8Vy+VCpdT/KaIUzKbhjX6pNU7tWgFl+yBeNaNW7u7X+FAxjXVoxrPwXoJ3E3Vw7KeNAfBmSGV/T9KLMix6BvjGt1QDr/Witn/ykQlS0VzQ4svlac+VEFDjqv0AK2++eiUvlzHmUGMK79AORDvpOu18++9WuJhF30Q1QFDhu18troSwTgq18EIl1H9xOJTOpveCblh9gHHqpQDMoY+w9MOvscX3aBR0CjN44DP0RZb3wpH9yqACCZfLrQFf8lwnLPdHrlXO9cVKuXk/inTLnn3AAE2G1umJdlCgAAAABJRU5ErkJggg== +git-merge-dark data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABjElEQVRIibWVPS/DURTGnysSC0HiZdWVrZ28JDaLT8BHaBsMdjqZJDXiAzC2LF5mX6GtATGiIsGARH+Gnj9X8a/kf3uWe3Py3Oc559xz75E6bK7VAWQkzUi6lXTonHsOpgYUgAZfdgmkQpFnjHwb6AemgDpQCiWwYlEPeL4i8JCEt8vb39g67vkmPH8yA3qt5nVgCzi1jLJBBEwkBZSAdxPKAj86LYQQQCU4cYvAKzDUSYF3YC+uRIAD8sA58ACU//VuTODE1n1g+A9c3jBH1tJ1a5TeCPNrdACSCpKeJG1IepN0LKkm6dGDrkqqOOdm7dyUpDNJi865PUnqjsvEObcJHEhaljQnaV5STwvszttXbR2J441KtB4LauLKVpZpYBDYte8mHUogZTWPrAGstTtQBl6AayDX7qHZD7AALMVGDvQBV5ZyETi2qHLtMvmXWRQAk57vBKgl4fV/0+jmq56vImk0icCnAWm7pB3riGngnlADx0TW+T4yL4CxJJy/Df20mkP/TqGHfifsA7INs3X5i3+yAAAAAElFTkSuQmCC +git-merge-light data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAACeElEQVRIibWVTUhUYRSGn/e74+iiQih1F9Vcmj9sptylUVBYkO4jcNeuJBdFKxe1CYQokGrRKjCEdtmqwEVmtqomQWeiUdc2EBUtUufe0yLHn1KLGXtX5zvn4zz3vd8f/Gfp90Qs0drmpA6MT1EveDo1NfV92wB+KnMdo39Nfs4L7eSHD5Nz1QJcJYglWtsw+iUehAuRRjO1g+0KHLerbb4OIHnHAC1FdW129s3XmUJuwnBDoOPbA7BwHsD7QWq1HKYN5msBRCpB1AueLoSROSkciSUyj5ClhE6BLtYC8CpBqVRabNrdMmIiJdQjuUbQ1WI+d78WwIbykxnzU9np7ejlNq2YxQ4ebNtTKyCyWcEgYl55EDj/a7ihFEtkLkr0As2YxjwL+9aem00dCEYNzvnJzLDvH27aaM5y80HEnKGHKGwPnEbT6fSOvzpAmrDQnkncpC7siiUzz2QqIPu25iOuGBorTufO/AJmH0v2ajHwuoHhrQHATOH9rQPJ7IjDLgs6kZ0F6it1AzArVcZLdUE+WnYgmv/uYFmz+dxH4NJGNT+RfYLCE7F4tn0pGkxHy94AmBm8/GfAVvIs7AukUTkbj5YdYIbZ9WJh8m1lzrrbNB4/tD+QuyPsdCibF26gmM/dY/NdRDqd3rEYeN04mswYL+ZXm68DxOPxnWXXMClsp+GGhCWBTtClYj53t1qXK78oVH2XYB/mHZ0pvHsN4Cczzw3rBaoGrJ6D5ZUvN1i+kjI0LWiptjmscbC88hZZCAf2trZeq1v0UsJ6wF7UAlhxUMxPvkW6AboQLbvPcjaO+BIx11cL4I9H308eOiLRQUhpOx79/66fNKzrOCYNDm0AAAAASUVORK5CYII= +git-pull-request-dark data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABn0lEQVRIie2Vvy5EURDGvyMUkhUU/pS2Q2clwvIKqL0Cm9ArrBcQShJPoLFR2YaIhIjQ7a7E39IiCgqVn8KsPY697E3oTHPumTPf990z986M9Mfm/A0wKGlMUlnSlnPuOQQAE5LOnXOFWErAIvBK1S6BZBAzZ2fzcckHjXwVaAXSwD2wWYN8A2iKK1ABt3m+ZeDRnseIthdgDxioxd1o662tfZIO7Lnf8xcklST1StqRdORxNEualHQIDDvnTmvdIGE5vwdWgLy93bQX0w0UgSdgKMC3AdfA7ndpSgKbduUbYBoI/7Ju4BiYrYFfAl4iBbxAgOyPgV9xWYDQ3xCXKK79C/wL/KJZoeW8QpsJCy0C54AMULaGmQu7sIAW4MpaxTKwbQU3U4dAxmLzwLpxXAIJP2jKgkY8Xx4o1SFwBmx7+7RxTUnVb9Bpa9HDFiR1/SRgWH+6FT3/h2qK6sBpB0aBB7yB880NcpaWtGHXjCsVBmb5PDIvgJ46BJKW84q9AguV87Adp/Q+9O8UMfQjRBKSxiV1SNp3zp3Ug/sVewPruexhKwhGXQAAAABJRU5ErkJggg== +git-pull-request-light data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAACwUlEQVRIie2Vz28UZRjHP993pi0QIC3YahNjirtmd3bS3Q1eUHvQEPUiEv8A4kXjwRJ78MCFBLjBBRKCHowHE38cNCbGGx6IUoKiodtNpoNmTJp4oSJNQ3pw29l5POxus2wo3QTwxPc0887zfD7zvu9kXnjEUfdNrjj5vJOmMP4e9JrfR1G02tuQD8tvgpck0dxCPwK30ViqnJTcr4bOmfRlI/PrhUJ5313woDpDpu8ss7f6nYHrvDnGcYlPsoY/bKaXwHY3HWfvgmNnMX0zvMM7069A3c3pkEYWa7UVgFxQPSfs7SSeH3k2rEy5jMubMBoG1yQ+SBbm53of+gCybMkk/H8VAFdbZisZLAFsJ11oyL+BUURcwrjWAZixXeIwxs/5UuVAr0QAYRjubGR+HWy3mb6QCIBXQe8nce0jgIkwfMo3/xLG085x8I9ofkMyUa0O+w2rgS0mcf3lboEDiKJo1cvsIDDr4D1DhTb8407hYhTdTJW+AvrdMnuhG9Je1m9BBzbfjXbyQcXypeqJLQt7+0rVE/mgYr3j7l7FDzOPBY8FDx6vc1EolPeNjI5/Jpgw7Lm9o+Pry//c/K0PhnLFyrSMDxE79jwxvn9079gvt28vrUD7V1EoFHalbltd2C7DfS4sAF4DTSdx7cL96LliZVriPPADuL+geRh0Z8il5SiKVn2ATENvCCYw78U/b8xdBcgHlYuGHQXuK5A4ClxM4vnXW8Lqp5JdWWt6h4CvWnsgxgDSbRZ3Gg0tCJ7sY4nGwDZOt/WBZtzN9FswLgM2sGann5mcPDaw5pWEHQH7cUu86SdkR3LF6tfrA814MNVpwNrM1leUxPXrSKfMeHcwdctyNotY8c3NbMX3LJsB3ZHsymDqlkHvYHYyievXWxPpSj4o75eYIuPWZof+vRKG4c61pncIx6gZsx34/5L/ACy3ElqUYhuvAAAAAElFTkSuQmCC +issue-opened-dark data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABxElEQVRIibWVvW4TURCFv0vlNEDcIHAkKAERJOIKSjoUArwBPwUFFaKIIngAJARCPIgdh4cgRBYt6ZIAEYEqdhpEw0fhCbkKa68dxyNtsfNzzpnZu3NhwpYGBdUqcA+4A1wEZiK0DawD74FWSml3JAJ1CngGLAIngU1gFfgZKWeAG8AFoAu8At6mlH6VtqTOqJ/UP2pDnRuQO6c27VlbrQ0Dvq121Fulag7q5qPmW18SdSqUd9Qrw4Jn9bNR21YrRQkvYixDKy/AuB3jWjocqKpdtXFU8AxrOTqZzp2PgvnaMRDUA+tB7mypG+OCZ3hbahPgRPguAR9LihaicEu9WcKxClzOi/fU1wPAk7rjgX0uEfNG3cs7mJjtE+wA5/olpZQEHgNf6K2NJyW4NeD7v7c4WptjSc0svlMjdzyM2fbdOyOA7x/T+7mzGj9H8xgIWuquevpw4HmsivkxwBdC/WJRsBKLqqPOHgH8aqybtcJlF0m1WLndUToJ5V31q9r3NOYk7Wh1Wa0PyK3HzA3l/4H3uzIrwFNgCThF7/x/AH5EylngOnAe6AAvgXcppd9DEWRE08DdeIou/RVgJaXUGYQzUfsL+zmwV7BtIq0AAAAASUVORK5CYII= +issue-opened-light data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAC2UlEQVRIicWVMUyTaRjHf/+vVUpOJOdilOYoUvVreq2CDmLOwdlAS5xMbrrhBifjYG7QjYuJw108nZx1huLgYlw0QshxChUK2koxHHcuCmgsFfieG0or3kGPSoz/8X3f5/97nvfN8z7wmaVqm9FodFfR/EnMugy5giCAwYwgI9Ff9Hl9L9Lp1zUBgsGO+rqGwnlhF4CdwBSyAUwvS1G220zHBSFgHtmVxYWvfp2ZGSj8LyAcjgfZphTQZtAr7OdsZvSP9RIJR+LtQhcNusF+d1aUfPp05M8NAavmg8AOk3cmN56+s1Hpa7U/cviUYbcw3jgex9ZCKoBgsKM+0PDuAdBq2He5zOiTzZiXtc+NxRw598GeLRcWTuTz+UUAp3ygrqFwHmgzeWdqNQd4PpFOy/gedNQfaDz3UQXRaHRX0fNPGdzNZUZO12q+VuHIoV7g5Hu/1/IinX7tABTNnwR2SvRsxRzAzOsBGuuWfAkoX5FZFzCVHR95tFVAbiI9DEwj6wLwl5YVMTRYLTDsxjuRrgGYYz/kxkbvbXRWMOBB24cKYI9ks1X8hXQDaAaa5XG9WjImZgV71wI+m8qAv8y0t1pSmP0ITANTmHO2qqvRZDALlTdgHKyjWkx2YvQ2cHtTacMxYBhWK5DoF4TCkXj7Jg02VKsbOwI0Y+qvALZrOQXMC13cKkDSJWBuJUCqAhgbG3uF7IpBcn/k8KlPNQ+78U5QQuhy/vHjuQoAYPndwi9gw4bd2ufGYrWatxz8No50ExhaKsz9Vl6vAPL5/KKzoiTGG0fOg1oqCbvxTp/juw/M+zynu/yTwjoD58CBQ02ez/pAR4E+M69ntf3/o1Y3dqR050oAQz7P6Z6cfPRRw647MkOhUMAfaDyH+AloBKYNPRT292rQHoMOSp09J3TZlt5ezWazxX97VR3638RiX9ct+RImSyBcrDT0ETMyMp4ptRIgVX7QL6J/ALSUEwJ5rdg2AAAAAElFTkSuQmCC +logo-gist-dark data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACYAAAAYCAYAAACWTY9zAAAABmJLR0QA/wD/AP+gvaeTAAACoElEQVRIie2WX2hXZRjHP8/4hW5UzoGFja2wdiF00S7EotAgIcKbMBoIu7HuuhANC3aRCTIQgmBBsEZ3s8hoIwomrLuB4E0YhqWFrjHY0NY2rGHh6uPFecXj2flt+/2mXoRfOHDO+z5/Ps973vO8B+7r/yi1U+1XL6gL6j/qZfWU+r7aca9YIgf1LtAL/AuMAOeA68AjQCfwLDAeEU8l+wpwCDgfEV/fFTq120zfq49XsWlVu3PPu5LPX2vI+4L6RrXJJnVanVEfqyHow+rnas8awH5Vx6pN7k2Vv1dvgnq1HFgD8Eq6//IOJ21QN6rr6w1wRp2t0/c3tb8w1qmeVK95S3+oJ9K2aVF7037+L9ldzF1dABWgHRivqypoBppyUB3AGHAFOAZMAY3Ao8DzwDqytzQBfAIcBa4CH+Rinr0J9mCaLFuRfcADheHvIqJaIftSzOci4soyBQ2k+O8A0xExUDSokPUyqwToAx4qjL1O9RVuA2ZWgFqVGoAFslUrUzvQkq7XVhHvZ6BVfUttWtF6BbBJ4ImyyYiYj4i5iJgDVtNI+4BvgI+BP9Vx9Vv1iNpWK9hPwCa1FK4WRcRCRLxKVmgXMEh2xPUAP6pbawEbTfd71gqWA5yIiKGIOJxAdwIbgDdrARsG5oC31eY7BVcAPQ38DZQdeQ2lYBExDxwBWoGv1JYaci6S+0OpJnUHsB74pTA1C2xRiy2JCkBEfJT+Kg4CF9QTZHtvkeyL3Qy8XJLzErBdfSYiflD3A9sSwO9kPfBpoBuYAT4t+H8BfAgMqceB+YgYpSj1JXVYnfd2Takj6oH861ZfVCfVwfS8Wx1Tr+Z8Z9XP1CdL8kWKOZ5sZ0qKX+LUlA7hyipslxzUyb/YnJeL0ahW66f3taJuAAWd129KkzycAAAAAElFTkSuQmCC +logo-gist-light data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACYAAAAYCAYAAACWTY9zAAAABmJLR0QA/wD/AP+gvaeTAAAD2ElEQVRIie2WS2xUVRjHf9/tHSgjRCA+C5am3gFmhnIHJypEY4aVujAYfCUGF8SFRhONgiRsiInxFRIiGhcSxQVEhYhhRQyycuMjUWaG0mnLLQ9BUORRiK20nTl/F1DSDJe2VOLC+F/dc873/c/vfDfnAf/rPyIb2Qgy4SI5njNjKTAb8BG9GBHSbmfeZwcrxQP/KlgqnVsj9CZQM9jlYL+JIYxbgEXAYrBDUaUYABQKBf/Yb2dXy6yzp1Lceb3BfIAgE66Q9C7wcw1v+aHK3iP1gXPnhrOcr6XD7eO/nytgvG2oD5g6kclbs+H9npgbdZQ2XwHWlM8n6a+uB51ucA2PRF17j8eZdHeXfgW2Drfd0KQflRj43OT2TQQKwHN8KjgBXAl2Q9/QMpndZti6rqtAxSmKfjgPPD1RqLHkyexhAM9p+/X2bm5rm9HS0tI4kWQL0uFeYE5UKc281uQgHR6W+Lqns/T85b5MuAjHWxgFYBjqDLCnP+mvnHHhQuNALbEK00Nc3FQDGCP/1Nqoo7TdB5qBQxNZFTDdjORwozWdSyF9i8dJnN4R3nHPc1PkvFsxd58/ODh5aKjRk1c7guwjQ29gnJez9cMeDa5Whou7cipwPm7WVCZc6ZwlRvb5ct90dZVjF+IZKxG+fC3pKZdPjrKgTQBBOnxNcKKns7ipPsAHDENx2RIbzTRtZF+tgSe4WoWlOxCnxoAalzygz1z8OVSdbM2Dvps56LuZJh4b081UwZgVpHMvNOXzyTHjR5EPHJXREjd4uFjsHf4O5od/jmWW9NnYP6R7gA+T/dUP7kyHvxi0Y/aTvOonPe3tR8cL5gEdwM3z5+di4a5F5XK5L6qUH63htZj0JLAFVENaa7WGfakFd6XH6+Vj7EY8VUXLgQ3/FA7g0pV2BNgBEKQXLgb7Tq72LLB6PB5edZJ9BZzFeLUll5t+PcDqFVXK3wMXJGuqHzPDiwU7XCz2mul1YFZiQF9ms9lrOGhVpe7pFKdUuu0BoNGM7rqhMxKt+Xw+UZ/jAxzoKL8fZMI5Eq8MOL8rSIfbwDokqoamGna7TA/WJ5vsoIx7U9lc7sD+YjGVWfiSZHcbdGP84SDhwQKJFcApBt3HdRZfGGw491d1R5AJt+LojTpLuy+DAUQdpVWtmYW7GmQvClaAbrRLtXDohGFFYHM1YXsu1wtbg7RFTquAZ5DXI/Q4sAwxzQDBWUw78dy6qNJ+bCRVVCm9F6RzkvSywTZMp4GbRi1/Uz6fbG5rm1EoFPxRA4G4i7opn0/OmzdvWlx8nGbPXjIlm81O6F33vwD+Bhvyhr7wtSBQAAAAAElFTkSuQmCC +mark-github-dark data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAB8ElEQVRIibWVu09UQRSHv9k1IgW7AY0RBWKsTGx9ND4qS5F/wEYLDb0xAY0UxkdrZ2NHYWfsaYyVT4iJwZpNjJooLBQEYz6LvZsdxmH3LtFfN3PO+X5zzzwu/GeFbkF1BJgCJoHjwFgRagDLwAvgeQjhR1+u6qA6q67ZW6vqjDpYFn5YfV0CnOqDOtELPqY2dgFvq6Ee6daWd1HyvPqyBPSV+jQav1H3tbkhMpgF7hXDLaAeQthUzwFXgE/AF0BgFDgBPAshLKhV4CcwVNTPhBAexKsfcfuGbqhdT1imA1+j+lV1GKBSxKeAWpRfTca94HuB+BTVgcuxwWRS8zCEsFbWIISwBcwl0x2m+jnZuKNl4RHjQMJYjoPNJFju0vxt8itiNKHTomqSO7wLeA3YE01VYoPvSf7Jfg2AU8n4W2zwPglO78Igrekw1enMDb1fXKCuUivq7Uz99Tiprq6rvwuzhSLpo3pLvZABn1Vv2nrkUjWLPdlWMFcEF9WD6tuo4EnG4HEG3Nad3KcOqEtRe/bb+ic8Uo9l8i/tAF9UB3bq54StJ3dTvaGOqofM3IuiRalW1PH8bnUKx4tVxLqYyTuf5Czl4JV0IoSwApwB7gLr7enMWtpzG7TeodNFbXmpNfWq6YloxYbUa2q9L+i/1h8/EAGdUrF9ZQAAAABJRU5ErkJggg== +mark-github-light data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAADK0lEQVRIibWVQWhcVRSGv3Pfy0zGdBIMRWk7iziMTpORptpqiNSNG1dNizaFFlxIoYuCC7NwJdiVlhYprtwpighCF9GtuhCRtuCoaWdqGF7HkE4aWm1CpklmJpl3j4vMS99M2k4G9N+8d8659/8u9553H/zPkscVM5lMf03do6iOKbJXIAGgUBJ0GpHvolKfzOfzCx0BEonRWDRemRD0PaC3zSKXED1fLfdcLJUuV9oC0ukXdluxkyq81Ma4VX9Y4x4p5rOzjwSkUvsSdMkVYE+H5oHmjM9IoTA1FyRM8JJIjMbokm83zUW/Vvi5raXwC8rnjWiPdXRyYGCgOyi7wUs0XpkAXmyEa/XV8qmZmZnqc0P7XrXKW2BuKDovgorqLoWMFb4p3rj2I4w7qcHCMSAOctDt7n0X+GiDT6NbrPsXDw50xftzKg7odvcmtXf4DsJTjXBpzbXPzF6/vmgAauoepblbnGTyQLvu2VQmk4kgxEKpvui6cwSCM1Ada5qheq5YzC5tF5DP59dQzjYlZcOzccgyGK65mC+2ax7IWPfLcGxhKARgV7i4vBy70ymgUMj+A9SDWGB3GOCEB/f0VJ/sFJBKjfQS6srAOwD8HR7su/7BTgE2Umn98u9uAgR+ayqpnOkUIK1zZMPTACjyQzOA11ND+z+E8aate4TMs4PD7wu80ezP940nJJMH+ky0XgKeAHkH1TcRXgNywFeoueJN//5T2CCZGT7kWBlV9CSwvwV6n/XuhOddLRuAYjG7hMjHgEH1tEb0BEgWeB44J8aebF2243Nc0fMPMUfgguddLUOoe/r7dlwWJzqGMCxq6q5Wz/jSVQZ+Nb795N69u4thk/6dT7uInNiyYcoU9ZW3FxYWfAjdpp7n1XzjHAbmUJ3wTXQ8gvOZb+oXV1d3zLf6WEcWW3MoJXX9w57n1YKUCdeL+eysOv4oyrQqn65Tv+1adz4WrxzaskXWNs0V5Jq6/is3c7lb4byhRTdzuVvUV0ZQPgDuA6jqlj+fqhvkllE9q+vLL7eab4Afo1RqpJdI9Rhr3ZeCQwuUTqfjvoket7WuS51cjP+5/gWC8y5uIkrtDQAAAABJRU5ErkJggg== +organization-dark data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABLklEQVRIic2VsUoDQRCGvzVWVj5AUHwM38BoFzBo6zMIFlokqOAb+AYp7LTwEbRPZyWYtGIlIiqfhSssYe8uJ2f0a46ZuZ1/Z2duD+aFuqVOrM9Y7RTlDYnAGHgCrmvubRNYDiGsVFWg2q+ZHLWvWhRfqJuwLlkBtae+qtvRHqmjXKyKxQL/DXAM3Eb7rCQ2G3PtgdpW99V2tHtqLxeroqjJ68BpfAIcAYcFsenNLWUzNnFE6p76nH54TY/pJXAHnJcK/HRMQwiPwBWw+u379THNCoQQJsBJYg+LYlX8zVXRJEU9AL4aCqxNue9DCBeNCABDoDXl+wBmFqg6ohYwCBFgkBEsJa1gAnTVl6pF6kFidoG3xNcFxrlFnfh/TXlXd5J3dqOvjAd1o06V/5tPH0lBqyqxKbkAAAAASUVORK5CYII= +organization-light data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABqElEQVRIidWUsW4TQRCGv9kYFzwCRCDEUuxJsCelyiMAXSSs1Cn8ChQg4QiQeANIpHQoQumg4Q1IGxvJviiLKOIWOgQx8g1FOFux1z58SoTyVzvz7843ezs6uGBJsbjj0oeKbgHXF6qg9NXkzS/dzx9jdm20T/UNwnfg7YItPhA1W8CNuQCEZUR2QvegtUh9m6S/UH02yzeLFKuiKMAmvmGdP7FJ+gjAOt+xzndiXplq0exAP8kVea6DfB9AVF7N8ioBQuj0gRdFfJQd7M7yKgGsvbesddZlwLsQOn2b+AZA6Lb3Jr0yQPyR62ZVVF5SN6sAKE9F5UnUm9C1lZWr0ZrWebVJ2irraOpckras83q69hvW+R+3k7v359+gomr5yXvgUNS8nguoOqZZln1D5ANwcwSNtnKZxvT//CrOU/E3+Cub+AbKrTNJ4WvotvfOBYCyCyxN5IbAPwPKPtESIpuh15bQawsim1PAEo1voPRB16zzP8sOWecfj8/pGvB7lDuNj6cAavKm5GYbYTzzMFTNsyKQPD9UkSGc2VOoyB2r0WZZk5dHfwA6M7v5DAVu0gAAAABJRU5ErkJggg== +people-dark data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAB20lEQVRIidXUvUuVcRQH8PPc1JYUrKWUDCmwoKa2QsTKoKEwxWhr6G1tqDVqaAn8F6I5oS0ipBp6WZqiAi1bEgIrIpcorD4NHvFye+7Vrkv94Dc853zP93venl/E/36Keg60RsRgROyOiEpEPI+IB0VR/FyzKgYw48/zBv1rJe/HN7zDKNrzDmMqfc2JoAVvk3xjib8zfa/R0ozA4WzFaAPMWGKG8vskLuDIiqK4lMHtDTAdibmY3+9rZrSnGl+piV9YqcpY3jwREUVRdEXEhog4GhGtETGJznrZHchMhhtUcCIxh0p8u/ADV+sFV3KAU2VZYBNmE7OuDsdj3K+XYGAfvua2jGXPOzLz2VzT/Q3iH2GykUCByyU/2dK50iB2B77jWj3ATjxNos+4hfG8E2mDJ+irid2LaXzCljLyQcxjDmctvkW1mFacwwd8wUCV72GKH6+X+TxeYGvd/i3je/AqRfrSNo6F5DldDS6y5LnVkFfFbcPHHGqRtu24i184tQQcytLOrJa8SuR8xh6ssrXhTm5bd+BmDq+tCYH12aYbNfbe3KbrYfH9mPhb8iqy25gusd/Ds0pEbI6ImWYFImI6IrpK7C8jojcwgu5m2dGFYyX2How0y/vvnN8dpHfeBcHNQgAAAABJRU5ErkJggg== +people-light data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAADWklEQVRIidWTT2hcVRTGf+e+mUn9Q3WMqbbWgJ1JnMw4meiAUkoxIRG1IKK4ELqoVkQXUsVuXbropoq4EWykuBDURV2I0r9GrC0VopnXTGaefYkhDahBOg4SnSTv3eMiTphMJ6Spq57dPd+933e/794DN3rJWkA+n49W54MB4AE1aoyawqVS1xn4PPzfAsnUg48iOgyaaIJ8I7r/5wn3u2sVMM2N7nTvbsQeB42J6nOOrW12bG2zIs8ohFblZHe6d/d1Oejv74/MzlU8lGibCfqKxeKVRrwzm43HAlMAatvviqdHRkaC9QScxkVk021DwAFRfalcGv+xeXN1bq52x5ats8Crf80vnL3yx29TiZ7e59s7tg7Ft3TEc5n09PT0tG08syoiEckBGF04seaVFtuOA1ixuf9OvQP6rqj5avb3SmlHKptdU0BhaT3L1gbLsVpRgMlSYdvNUb1VlKeAqBFzsjObjbcWMNYFCMxNg2sJmE3B4wCOmEK957ru/KVy4UtxnCeBO2OheaOONX9Tk+zJlRXsUsTunLl4sdIIplKp9oC2MYR//FJ3T6uZSKZzZ7Es+OXCIECkOQFVeUFET8UCU0im+w7WMydWeyKwHEboUCuDaw2cKGpFVx76qjmYLI+dRzkE3IvqZ0RrVaK1KsqnCNsROTTpjX3fijyReSip8LCo+aFlRN3d2ZR15COQnUAFOIVwedmbdCI6CMSBc47V/Z7neivkqWxexHwC2h4lmi2VRn9dJZC4Pzcghi9Qaoq8dfstztHR0dFVvyqfz0f/nA9fFNG3gRhqnvbLP30LkOzJfQP0Kzw7WSocW+Vg+ebmAjCjTrhncnz8cqsI6rUjk+80NvgauMex+ojnuV4ynTuMcgD4G/RNv+QO199ArGOGUWrXQg4wVRydCTF7gCVr5Agg/kThIE6YQjkH8mEi1bcPQLp6+h5T9AToy37JPbIeeWN1pXOvqPKBFR2amnBPA2QymdiidY4pMmhCEgbsXqDSZsKPN0IOoIvzR4GqsbK33isWi4vG8hogoaOvG0V2oXK6WCwublTA9/0FgTMIuxr7nuf+AowIDBjgboz6GyVfcSF4wLarAGEcuC8iyj4JuXC9Ak5o3g8j4fnmvpXIe44NWg7kjVX/Ap7dYx0LcmfJAAAAAElFTkSuQmCC +person-dark data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABaElEQVRIidWUvUrDcBTFz79YnRSii4N2cVB8AuvgByKuQqmrILQPoe9SfABxV5wUna2IVLq1k11adffn4A3UNPknjQ56IARyzz3ncj8i/Xe4LCSgIGlD0qp9epJ07Zz7+HEFQBl4YBRNoPwb4u9AB6gA0/bsAy3gDVjLK+6syg4wGxMPLHYPZGp1VGDLWlHxcKrG2UziFDwe4UAvPZyLCHcsgwlPLETYmkSuz6Bp7x0PZy/CzQ6gYENuAUFMfA7o2pB9hXpN1m0VOzbQGXsOTDz/mpqBA05ijizEcZpG4v4CK5IaksqS+pKuJHUtXNLXbAJJd5KOnHPP41S+DbwCL0ANKMZwikAd6AED3y2MVG7ij8BiBn7JuANgOY3sgFurPFU8YtIDbry/DWDXhlfLKj6UW7fc5LsBToE+MJnDYMra1PCR2sDZuOJD+efAt22KXuC8pHZeA8td8FVQBZIJKQCWgMO8+X8Tn12zhtgfmPjeAAAAAElFTkSuQmCC +person-light data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAACSUlEQVRIidWVv09TURTHP+e+2mJMEOLiQBig4GtN4WEHRQxGMfHHYkx0YpJJR+OmjMTNP0CjRv4DJyNETXTQODRQSkmJTweYBUw0KeX1HoeCKY3v0eKi3/Gdc74/zrs3F/53SJN9Jul6owhpAJQlvzT/HrB/LZB0h4YR+wjI7B6UBVW55ZfmPkY6a4J8FugQ1euOLbc7ttyuyDWLJhA7k0wNnNpvAulLefOKdiZM4BWLxbX6Yncm0xkPTB5lzS/lhwBtKUHS9c4qOiCqdxrJAVYKhXVE7iIMJt2h0TCe8BVt/1Cjm7OhPZXETK23mm5ZQMTGQom3YW0gACoS2hsqoNbJAwTm4FjocFtwEcCozYcajTBo+lLenEUTWzE7vFIorNcXXdc9EpCYB775pfwJQu5E1BqsVW6L8CoemHwy7d39vfN4+VJgeYhwGPRGGPleCQCkN+XdE3Tqz1W97y8tPIgkCCv092dc68gzkGFgHXiNsFrLJt2IjgGdwAfH6sTy8sJy0wK9xwbPieEFSlmRyY5DzvNcLrdV35PNZg9s/KzeFNEpII6aq35p7t2eAjXn5hOwqk718pfFxdXQ/EDP8Wy3scFLoMuxerIxSeMxFeuYpyjlZsgBvhZzK9bErgAVa+RJo+ldAn0p7wJwWpHJZsjrRVRlUuFMT3rgfEQCOw5stDlb082S70CCH9PAd2NlPFRAkRGEN8VisdKqgO/7mwJvEUZCBYCjwOdWyXdga7Nd9d9232SRCSo28oWKgjjVxxrElvY7/2/iF/Bu47CZ2fOnAAAAAElFTkSuQmCC +project-dark data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAAzklEQVRIid2VuQ3CQBBF/yAKoAuDxFEDgXuAHJPQhJuAFiiDHmwJQRc2CeEjQJbQ+kDWYhn42c6s/pvd2UP6GwFbIMdfORAVvvYCyCVdJJ08a11KCsxs5K4AIPY0FxADFOOBr+E71QKANZA4sQOwd2IpsKrzGTbAA0kzJzapmDeVNK4z6W+LfgbQ1IMqnTsFmNmuLaDXHlwlJQ15P4CZHc1s0RmghVI9H8lKtT1FJZnZvCn/XxftJikE7p6eoaS8FAUiIPvAl5kBG88iv0gPgTHUJC6qAYQAAAAASUVORK5CYII= +project-light data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABB0lEQVRIid2VMW7CQBBF/1iuUe7AIrwIbFcU6VJwh3CBNLQ+AH1qaDgAR6DMFcCWsKM4hzANnX8aLJmQRaAFAfnV7nzpP2lmVgs8uqQ6NL3wTYTvABqWmRtSou9sOdsDKB0UADIAH5aAFwDtPF09AYBbMxoQWeTr5dgmXXXCMch+dXdswk6REdDy/KHSQVyvKR1OlfYn+7UgaWr/1ZTjmgw6Thtk71dV18ZWqSvieKac27XoYQDGGfwtrq8KyNN4dC7gdjOQsvwUSGzyT5WxRV9ZPAcwtwVcokUJWWYm88wtOlServxj/v96aBuQA6WDrVUiOQBQHABIiXZf5rMVACgARpYZd6Qfp6RDgj1llLkAAAAASUVORK5CYII= +repo-dark data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAA+ElEQVRIie2WLU5DQRRGzwUEirQCQ9BsoSGtZQHgSFgIsg4JXQCGP8V2QCBIEATZkJDgOJi+MJnMo30TFHmfmzvzfeeKyZ2BROpQvVHfrddDmhkZ4BY4Ai6BD7prAowjIoq7i85nFcGNf6qa1tayM1vAvBZQUg74c/WAHtAD/gMgn6YCT8A2MKwOTaZpCfAF3AGvFdlLx7XqdUVw4186rgWeE8Nn4cU67QLNAS/ASG3qmwVPqdaqjWw9A86BK+CkzaTuAseFBse/AiLiQg1gLzs3Bwb8XIp94AxYL/Af2xordap6v/htHKhv6nTlgBUAh9l6Rx11yfgG8ne/zwh2OysAAAAASUVORK5CYII= +repo-forked-dark data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABoUlEQVRIibWUPS+EQRSFz0hsxaIgIZHYllDYiiiUEn6Bn0Dho6Nhf4GPjkYn8ZEgGqFRSNBoVTQKdhEROsk+mrt2svvu7Gutk7yZzJlz77nzztyR/hmulADSkkYk5SQdO+c+QwmAZkkTktolXTjnbkLiDJCniHsgFdCnTFNAHliuJE6bYANoAYaBF+AwYHBkmiGgFdi0HINR4lmrotXjVoG3gMEbsOLN2yzHTIFr8PRZG3s9rs/jo5At0fd6fFk1TfY/X4A14MyqmQrsYNo0pxbzCtwBTZUCUsAh8GHCKaDspnl6ZyZ3FnMA9AR2/BOYBzJVhUV9BshHrTVEkZKeJPXHNZA0IOkxttrrhzkgGdAlgXnTLv3GIAHsEh87QGNUrooHaEajkoYlFXYxaeO2je+SLp1z57Grr2J4DvwqWaVDrhv+3SAWrMvXgWcgZ10b3a01GuwDX8CWfV/AXr2Sd9lVXPC4ReM6q8XHOYMOG2897rZkrXZY0+WAK6DHHsRr4xJ/NjCTcXstC/gAxuPEBju5xKRb0phNT5xzD7UUW3d8A4p92DZKdSwEAAAAAElFTkSuQmCC +repo-forked-light data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAACuElEQVRIibWTTUhUYRiFn/fOdYyoydQxk4LEGzN3RudaLYL+qRaBQYsIItoHCW37ISNbRwUFLWoRZBEt+4EIooKoTdZQ6TWaNIgouzJkuGhG731b6JTojDNBntX3ne+c97zfH8wzZCbREm9bZ4hsQvkeDvl3+/r6xuYqEIvFFgdSvRuDqCrPMu6bVyUDrITTjdI1jR8KBbrj/fs3Q8WLp5p9Qx4BzVOUInIm058+XdAY0ztH6RLhSpAza1RlI2jENzhfqntfjAugEdTYMFEtS0GvonrKslNrZwWIhDYDMh6Wo4ODvaMfB9LPFaMHZGvJ8xHdAlzPDLx+8Smd/pE39SggAptnB2gwDBD6ReJvhSCpMFyq/uSa/NFX5UMJgGCaxywMwiH/bi4wh0SCOy1x5waiCUF2gnSW3AByEfSSZTsPVXFF9CDC4ALx7xU0ocLA87x8tG7ZHRUShsheVMKInMy46culArIj317WRpd7KB2GsAl4bKoccN2330t5ALBsJ7ASTvecoun6hNNt2U5QbM0oRip8E6Wt0gCUFPC12FKoGFnX0BgBDtVGG3/W1qzqz2a/5IrpLGt9pLahvhPhCKrnsiPDT2dqZv1kgGQyGc4FZg+wr8I93F6y0DzY29s7XlHAnw7j7dswgg2oRCYZPTBluzk51VEwXmQG0k8qbGRuWHbqiWWn/qlY0Uv+n5j3gKKvaCaSyeSimrqms4hsB4kurW9c0bSs/pnneflyXrOcACCn5jWEPSr0AAgczvlVTVT+ykojFlvTZNmOWvHU8QJnJVInLNtR2163vJy/7B0EpjYAqBhugVMVF8A3goZy/rJHFGa8P4fpCXosHm9PqwbiwzHAqyLvlvPP+dEKWG23dyh6C1g0RY0Jsv+Dm77/XwIAWlpbVzJh7gLAnHjw8d27z5V65xW/AVGM6Ekx9nZCAAAAAElFTkSuQmCC +repo-light data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABTklEQVRIie2UPUvDUBSGn5PW2qnYwUXExWhNpY2CUKT+BB10E3R0dnbrppuKLgouHbr5X5qlKRIFB+cuguBHj4O05KYfNsVJ+m7n3Hue54ZcrhDKQqGQTX1YVwg7QIbx0gh8b7VTJMMrqS+5RtgD7oDX2GhhC6UcbhkCVLZVuX1sesex4YCdX6uAGgIrsicjlrTGgQ9KVPDnmQgmgongPwgkXNiOq8ADMAtkx4UGvtflJvus20ANeIlN/vW5/kkt8L3D2HD6P9dRgSI8dQcc9w1IR3acBE3vbFSp8ZMVnoFSqJ/unZDe3pAYXyDIJarn9opbDZrewaAh2y7Oy5S1r6h5QG2Xxbw3piDw6xe244oKyxFmC5ihc+tS1qaqngKJyAEBGmZvSGzHVYT790T7aPozsaFoFZGboFGvDJsbOYuOuxuuc7n1uaV8sRSH8Q1DUVLnYLty3gAAAABJRU5ErkJggg== +shield-dark data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABqklEQVRIibXVu2sVQRjG4W81CiYWJsFCiHYplHQ2XlNZeGsEQSu1EQtFERsLwYCSf0fEIimDt1YOXtAqilioJGhhNKJ5LPwOrrpnsxvjB8NhZt73987Mnp0tomFhKCIOZne6KIr5pt466DBO4Q4W/apvuI9L2NIWOoKLmEkQPMckdmabzLFu2Ex6RurA43iIpTR2cB1jNZ6x1HTSs5SMfVXiN5jFVYy22vZP/2h6Z/G6SgATbcEVnAno9te0NO/GrjaevjbiiLiRvwf+V8Dalvp2R7SSKgcsRMSGVWD2R8Snbqd8RHMRMbyM+XuDgM0R8b4q4GVE7FjGfK1BwPaIePXXaL7+i+hvAKksbMRX3OyOlZ/BVESsj4gTNYCjOFKTcTIi1iWrEtDJVvl3xEd86DHXhyd41DMex/PKuNxj/jAO9Zi7kt5jPQNSeBsL2Fsr/N2zH59xq4l4CM8w1+TewR7M4ykGm65oG17kqs6iqNAUOIcv+fHZ2gheAgxiOs/1bnk3eavey7kpbGoF/2OVZ/AuYQ+ywVucrtrdSoIGcAGPs53HwD+DV6N+ACJe1wlenNZwAAAAAElFTkSuQmCC +shield-light data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAACs0lEQVRIib2VvW8UVxTFf2f2g2DSAKKJSAo8CzO7eHaIGyCAXESJJahsIeIKESlyR6hcUSDcIP4C0kVICInIjkgDFRQY5CbSMrAskLXlhERBfKYxKGv23TQ2stnZtTdRuN2dc885d47ezBNrrFKptKnRzA0C5DMLV6vV6ou18NQJDIJg84LyB4UOA18A+UWoiZiW2Q9ZcpdqtZ//XLOB70dblWfIjCHQPiBj8EDSpLnmBIC8zLCZDQl2AE2wKYlJazBZrye/pxoUwr4Dhs6AdgMSSszcpImJmVpyN2273jDaKWNY8oYMiwADm3aexmart6dWGPhh+Q+DhozvnDQxW6v80im+d2tbGBc8s2ETozKy9fu3P14ZTVg2vxif6kY0rfxifMoPy7bUe12Rg117/DDa3Q0n282w5MYXU/38fzEwyHQzD11G9G9qucErM1v/XwXNXA8wv9Qvj+i5YHMnsqC5qoO0xYynLQYGc8KKHbfDTq6qb4Rgvy71byOSNAXq/6i/v6cduV5Lpuu1ZLodXiqVPgQ+FUy1GHi4K0B+w+s3R9oJFILyoUIYH2yH/+0yXwE557wrLQYP7yU3hBJMJ+Bw6nE0ccGwC2nYwMBAFnQCqMw8qNxsMQAwz40bFvnhw+NpIg6NmNxIGvbo8ctvgZLB6XZvCIAfxJf9sPyqd0f8WcfBZbW9GO33w/JrPyz/+C7W8qGtyywcA5uTZz+t5b/TG8R7nekyMNvIuq9XNahWqy+clxsEnoGu9wbxN6TffCoUy6OSXTN4Ypnm4G937rxsGWq32Sd9fRvzTe8ixpcGN4SNLR1RP9i1R3JnDfZhXH3zgUbmKpW/0nQ63smACkF81GRngS3ArcXnezGeGBqbuV85D1hbgVUMAIiiaMN8Q8ckRhdp53py7vskSeY7M99D/QPdLfLwabXIewAAAABJRU5ErkJggg== +shield-lock-dark data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABzUlEQVRIibWWz0pbQRTGv0liQiUuFFpQH0C6KLjTjYJgsYUuREWo9QGkW8UnEdEs3EjcSAsthWarbcGNuFWXghtTqotKbMT8XOQEBxzm3oR64MK5Z74/M3O5Z0Z64nBJACAjaVTSrKR3Vv4m6ZOkA+dco21XIAtMAGvAOc2oAV/tqVnt3DATQDZxpsAUUAIuTOAa2AXeAz0etsdqu4bBOCXTyIQMdgx4BZSBGaA7xWq7DVs2LsB2CHgLbAGFVPsYNiuYRr1V85eSk3TmnPvXqYFxzyR1hQyeJHJpgcAzSR/steycq/1XA0k7kqYtf6vmf5EY/hY1FN+yKS9/k6B5FzL4I6k3Qqx4+fcIrk/SZcigKqk/Qlzw8sUIbkDSRcjgSM2eEwzn3E0oD8SIpMOQwZ6kQeBVhBwNYFjNFeyHBvusr2xEBH4Aj8kP4yXgLxD+lsAmcAMMdTD7l8Zdj4FeAL+Bn0C+DfE88AuoAs+TwPNAA9gOtt3H+Ix14gYwl3ZGq9Z2PwPFCK4IfDHscipxj/zRWvgJMBkYfw2cAnVgqS1xT2TcDAAqwKQ9FasdA2MdiXsmBduy1tmM5StpDqfEW4VnlJd3q3DO1WP4VtwDOAHAyXAqGXMAAAAASUVORK5CYII= +shield-lock-light data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAC40lEQVRIibWWyWtTURTGf+cmHbQW0Rq00ZV9NY2xJijisHBXnLpQo4LT0oUDdKEuxX9AcCEoogXBYaFGReqEKyd0U7SF2kZepRQparRSatK0Td5xYSsxzUuj6Lc6797vfN95553LffCfISVwTF1DZI0YjaI0T2S1qSOx3p43rwDnLwx2euoC79aLIQpsA/xAGuExAEoTUAkMALfVIdYbX/IUbmSLGRirIdyEaBRkK+ADUqjcF5xbRkfb4vH4MEAgEKh2pKJZMdsR3QzMBBKgd1CJ2T0djyff7JdBXTByTdDdwBCi98SRWLLK+3CgvT1VrAX+lStnViUzG9VoFJUtwGzgit3dsf83AysYHke5TCZ50Lbt0WKibrAsqwJv1TmEfXZ3RzmAydn3YqT/b8UBbNsexUg/UDa5Zorw/wm8pRIXLVo7o7I6uRcgPVx19cOHlyP/1KCyOnVtYrqoqE5tAqKl5OW2yFHVYi3bMBkIbHQjTWj8Og+5goMizHFLFHiY8/CgCG8u8K2AgSaAWrfE8ZGhPZNxJjW0r4iBH/g81UB4jbLGLbGvry9dKM6HwmqgfYqBOuYJsHBxQ2OjW/J0qA9FIoAf9OkUg0rP+E0gJZjDbgICzxWeuVbvOIeA5JhXY4UrWBo+bwXD6UBgeeCPq1+2ImgFw2krGDmbu/7bWDpePQH6PeuR1lAoVF6qeCgUKtds9iIwzLicdDXo7ez8jMghlHWjjrc1f98FJu2UXQLWYvSgbb9O5G568tmDiU9dNb7aEeBojW/B8tr58+4lEokxl8pnza5ZeF1Ed6hyvLe7szWfM8UAYPDLxxdzfbUJoCWrZtc8n7/n65eP73M59cFIU0bNXYRVIhyxuzvOFNIqeifXBxvXK+YCsAThkVFzCsAR5xjKBoW4R/TAu7edrpM17aVvWVaFlM1qUbSFn6cUYEBFT8tY6sx090cpfxXAz0kZy3qbAco9mbaurq6C3yUfPwDdEQsxFn27NgAAAABJRU5ErkJggg== +star-dark data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABsklEQVRIidWUOy9EURSF1xaFAgWFd5iIUZjGD/AL/AhqCgqJelSi8EgQZLxKPf6GxihMJB6tKEa8k/kUsy83g5lzQ2MnJ/dm77XXWudknyMlCGAamErSk4S8G3gGXoDe0L66BBqzksz/ZxK5qxVAB/AIbAJbwBPQ9ZcCy8ArkAJ6/ZiW/oq8DXgAcrHctu+i87fkDcAq8Ab0x/L9nlsFGqpxmDf0SRqSlJY0EFs9Kg/CvpmNV4jvSRqTVJJ0I6kQW+eS8mZ2aUBK0oU+J+SuAliQdGRm9xUCTZJG3UjcWEsEkZQSYD4ZABs1z61GABvOtQVYlDRgxQvrH4VkxAYsRkaBumqAzS+A2uSRwbWqBoE5B+ZCRJx8zXsWQh1FIhMB2EnHzn1X/8nhrn9fA/xEmJ0kAhn/ngYIRJjMd8WfBIZUnuOzeNIfvY4KbN6xiQQykq7MrOjErcC8yhfy0qesXZIcc+2mwgI4AQ6BZiALFP3tyfl681zWMYfASSh5vb+UeeAWKAEHwGAMM+i5kmPy3lMfIpDmM46B4SrYYcdEkQ4RaPRbORK05XLPiPc0hvb8n3gHRCXiyIC2CgcAAAAASUVORK5CYII= +star-fill-dark data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABLElEQVRIidWVTUrDQBiG3ylddNG9+IdRMC7qpgfoWTyCnqFLF/62UMGNWw/gTdwoiMUzaCtoHxeZ4BQn6SQdEV8IZPG9zzMzCYlUIcARcFilUwW+AUyBd2DrNwSXfOciNnwVeHMEE2A9puCMnzmNBV8BXj2CCbC2LLwFDDzwPAOgVcYwFpRI6khKJe0616akxoJ1zCS9SHp0rgdJ98aYZ2PhT7ksYpC005A0lnQdGS7LHGcqMMB5yVlXzQiYP1orOYkAHwLFxw30l4AfBx1eTUk/+OkA2zUEiY9V9I7vB69mQefPBJ0aAm+nWWH4Q9KNvT/wdMN2DTTJ/lx5ZsAtsOfMJMAV8OnMTYGiBc8JUqd0B3RLZrt2Jk8aImiTfTZ6QVvOOj3baYd2/k++AC+3Yx0GcXS0AAAAAElFTkSuQmCC +star-fill-light data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAACG0lEQVRIidWVMWgTYRiGn+/+a21EClGrERRiTWLShrbiUETErDq7u3QRF0WhoKN06uYgKEVx1lGQLjo4OTUJ2FzSpBrEQiCkGYPm7nMwlZBe2rvaxXf6eb//fd//u/+7O0MIJDJz905MnJpvNRufg2oksHli5iwjUgXExUp9La3Vg+isoAGMyiJwBBi11XsQVBaog0zm8plfdGtApEd1LJdEpVL4sZ82UAc/cRf7zAHGPKMPg2j37eB8NnvauGYTODpQ6hjPulAur23tpTd7FePx+JhtIkvAVZ+yraJj48ciH9rtdneYhwCk03NxV5hWNAWSVLykIEngHPs/Rg/4ruiGYG2AbghSMcoXx8l/k3R6Lt4V3STEyAaE2iqTluPk66Arh2wO6Irj5OsGoNVsvIuejEVFmD8Ua+V5zSneAfTvJW83G6vHJ2LjwJV/tH9Wc4p3AYWBKWo1G6vRiZgRuH4ga3S5Vire7+d2jel2s/HxICEKT2ql4qNB3ncEbU9fhTEHGFF56cf7BrhCNmyAi/pqhr1EoQN0iGZIgEyHDUDw1dghNneB1731bR9tsA5yuZwNZPooBd4YT7PVUmGhWios2CpJEV7w5zu0g0xPO3DWAUymZ1OWUO6V3yP6uLpeWPM7XWJq9hIqS6A3ADzl4qZTqPTv2ZUYMd2tjms/NZa+rawXPvkZ76AXfDM1NXPN9eRWxHT3/Df8n/gNrfGxihYBZk0AAAAASUVORK5CYII= +star-light data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAACxklEQVRIidWTT2gUdxTHP29mjMnBgkqNEQsbd3ZnfrvLzEoOnoRc217roYdSEQTRS4sFQbGHgK0UqigoKtKq7a29itdAT540IYmZ3cR/bYVAiVAvMZnfvB52NVtcdyd48sHAj3nf9/3zfjMuGyjf1L/a/uGOfcv/LN3NOyO5yf1oN5tkARCLU3704N6TPHNOXgEG5MQrQ65k3+Qdy5XAmLGRNdJFEX5RVQH5wrH4jcbU3/1mcyVYxZ4APMfqWYt7BnAyV3Ol6CswWqsNC3oYuJUk04/au/8V5EgQ7N31TgKFQmHQte5pYADXfv+64drvAM862clCoTDYi0MAwrBesEJV0TJISclKgpSAjwBH0JvNB9MHOwdLJrqhyJdABvypaFNwmqBNQRquMjs/f/+xBEE0ah1ZZP3Cl4Em0BRoZGjTy17eTpLkRadAEARbUmfzpw5SUigDpfazrQ1RizMKIL6Jrvkm1mIYX+kVN08Vw/iKb2L1TXStwzRSDOMLrUb9Mhv4ATtKfBOf6zD6xv2+BpQq8dVugF7k6wajSz0NFk080QLG13OKiG+iS76JtWiiH3LZWRepH+2H9U39WIs8nujW7+rQy/Tn1ilb7SegyirAJpWfcgtYodY+zvQTEGQGwKK1bv237FiqgLI2NNf51pixEWPGRv4HTQdmAVXYiAA1hScLC3f/BQjDcLtv4rNrpItrpI9LlfhqoVrdCdDGPEWodiPyugegCsz6/r4PGFg5nipfA0PATVp2D3nqfe5X4vOsDv6orMzIWxK88b2Oj497fy09fwE8BB0G2Qb85mb6bZJMJwBBEAXWkQngAOgyyBKwZ/fw1i2Tk5NpT4E9YVx2hKTdvoPoqYW5qXvd3PmVeC8qZ0A/BsiU4OH8VKPniobc9NmK9S66jv7emJv6oxvxq2oLf1KuRPttJp8NuemzXvj3s/4Daoz4w62BFyQAAAAASUVORK5CYII= +tag-dark data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAA/klEQVRIicWUMQ4BQRSG/1E5iIjEAdS4hwtwCBUdJ+AeIjqdlkT0REItUfBpRiLszM6MDa+ZZOfN9+3bN2+lXwQwAK7kxwaoxbCNFVwlrSQtc/I7dm0ZY7YxFQD0A/KqwB44AvUQdin4LSQZY3aSmpJukhYhkihBisQpAMpAF2h8K8nsAdCzz0+ec7k98X2ilaSzpJkrIbiS0FvkCl8l0U3OCltJW9Jd0vxjGIE1cIid0vcAapZzACqujSIkF2DisydLgJHtaS9rM/pX8HZ+aOFjX1KSJAieKomCx0qS4KGSr+B5kkLgLkmh8BfJc04uFj4qDP4iqQDTzCH6VzwAiELCiF8OvUMAAAAASUVORK5CYII= +tag-light data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABgklEQVRIicXUzS7EUBjG8f85TYQVlzChmjGTMWJj6+MSWIqVzMYKG6zYsGThUmxEfCzsJCgx0zTDFbgBi3NeG51MhranM8SzbN8+vzZ9W/jjKAC/MnOIyDYwlDPf1MYux/FzVAyYqn+guEO4zRoWWAPwjF10RRJAUOqg3XzczxoeL9cDrbgGPBRL7Wb4kgdol7tI8haFsRUWAINw6Vfq1V8F+kFSgVKpNOxPzWxMVqbnBkFSAW94dB3kVESdDfIkqYDWcge8I5ynzbgghbYoLVnbVfgl/5S3KIy1sUsCVoSLIKiVe4EXEWl0nyiaOH6OPGMXAYynryaqs34H0MauJCd+A1EwpqzZ7QC9+iCI9XQDGBHhoQMkiAjzCsR6+sblK+2NX6kfAZsgJ69ReApfW9Sdfv43nXJhB+Sk3XraTI5/A/pB0spTgSJIVnkm4ILklecCWYhLuRPwEwKsupQ7AwBBUCsbT18pGANGgON2K9zKu84ZAJiozvrKmD2B+2TP/z2fmn7za1yyQLYAAAAASUVORK5CYII= +tools-dark data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABvklEQVRIibWVv2sUURSFv1FJoxFULIKwGhREJYlYiYUgpEjqVFraK9ilCcZGBBFRSxEMklqQ1PovaBVBCzWKP1IlK2ENxs/CuzoMb97OuuQ29809557z7mPeDGRCHVGHM/iwOpLTyInPqVvqmnoygZ8KbEud61f8jn/iceTZBGc2sIXIt5uKT6u/1LvxrDqf4M0HVqj3o2eql3ihvlVfqkNNDGI9pL5S36hFmbej0ncBOArcLIpis9HIQHBvAceA8zmDKWADeNZUvBRPgQ4wnTNoAe+LouiUaqvApHqoW4j1JPCtNEUHeAccKQvuqhjsB75XaleBR8Cyej1qN4CdwOUKtw3sq472N9RF9WuiPqou+S+W1NEEb1V9kjO4FgLHa/AJdbwGOxG9V3IGh9Wf6r1aUn3vA3VDPdCLuKD+UM/2IX5O3VQfNiEfVD+qK3XHUeFPqJ+Cn999qWlc/RIjX8zwLgXnszrWSLzU3FJfxNfydAI/E9hztdWXeElkLN6MmQQ2E1h259WbXI09kdsJrHshdw9isDdj0K5w/sug+7tcT2DrFc5ABrkJBjLY9iP6ACyTPqI14DWw0kNje+M3kb+gsxbDFRwAAAAASUVORK5CYII= +tools-light data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAC3klEQVRIibWVwWscdRTHP+83Ww3aLCgiFEENu5vZ2Y27mxaseBCEHtrSetdT8aL0UJEW2ksp9iCCWIScPbUVS0HBP6EXq1hiNh1mt02y25iLBg9tpa7JznsepsHt7swmBnyn4ft98/2894OZH4ypIDiwz/f9ySzf9/3JIDiwb1yGyzJKQf38Jv212E2sFauzlWG/WKlXYzextkl/rRTUz/8nQLFS/8LgomCXgTyxvjPSpBwH8iBXDC6Wyo3PdwQoVF47gvEx2Jd3o+aJpEsmRt9MtKXolxNizJnY6UK1dng7gIh5cxjNp118Nm2ilLKnvP4ZQRZFZQ6QTEChWnsbrIDj0zAMN3YIIAzDDUw/A4rF8uxb2Rsoh4FH/Uf3v99p+FZt9h58B/QQPZIJEORlsHvdbrc3IK+DHZqerr+0JSTPdgjj9y2t2+32ELrAq4OZuaFBnheTPwcFw06JyVfqERWD+oVkUT7B8Ex4/4kBlYcmPJe5AWLrBq8MSstR8xtPbcbgBnAJuGRww1ObWY4Wrj0xjDAF/241CjD5GeFF36/5g3K73ewsRwvHnHONWOP6crRwrN1udgZ7SjP7A+AFE37KBMS4b4G4L3KSlLoTzi902rebaZ5pfBL4a4/+/XUmoBPN3wO5KsKHxaD2RlpQWhXKjTcxPgC52mq1/sgEALApZ4B1TK5P+TO17cKnq7N1EbuO8VvOeueG/RHA0tL8eqzxUYQ9nvNulsq1d7PCS+XGe6r6A+AUPTo8ffoGQKd9u6ku9zrwo4lcKVUbjeGeYlDbb2KXEW6qyx1caS0upmVl/q5XwluranoKcMRaGPbFmAKcqn60Et5azcrJBCRmbi+AIQ+HPSP5IJ3Is+MzxpbmH7elARLNJL97gGfJden0wYgniSZY5pW6LUBMJgFUciMbKN5jze0egFkeoO82RgDPbGlOd39EiqyCRauL5ZEjCsPKfYSWmvw6dsj/u/4BTw4aJ8iEHMkAAAAASUVORK5CYII= +workflow-dark data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABLElEQVRIie2Wr04DQRjEf8O/C4qEvgOWBIEpCkMTEgQBhUVgeAbOYjDoOhygSHgHFOEVKIaEFIO5IgbBXlJxu2WvKQkJY+7PNzszm/2+y8FvwPau7RfHUdm+tl3kaisYDIB34D7CWwFOgGNJ/TY7sO1yWk4T5rLT/Bv8WYMRsBoj2e6E26qVi+2bxJDVGNlez9WuB60AjoC18H4L6ALn4fkDuJP01MqgYUclcCYpVt8AtoH5iO4bcCWpWshNFMQfgElre8Bhmy7aCeIdRQBcAnsQb9PPkHaxoVYASBomQgyBpZTBY7ieRkymg23Zvk317IT1Zc1pPChJtn3A90FtAstj5bqFZ4PxdD/hzPxblD0HwADA9gXwGuHs17xs2C7CD0CV6IFn271WBrn4Aj4U/yN7l7QYAAAAAElFTkSuQmCC +workflow-light data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABqUlEQVRIie2UMUtbURTHf+fmqdQKBR21k8+SF9AXDHTRyaWBbqWddLZCyWcwawc7ONUhW2YHBeNX6BSDxEB57ZJJkICgAZPHOx0iVfC+0hvJIHiWyz3nf87vcvnfCyMOAVgI8u8V3QNmU3Q9VA6IrzaiKLpxAXgAqvodoQNUbSKFVyK6xdjkMVBxBiDMIVKJzk7KaUI/CLcQ89plOIBxbXgGPF1ATxKdThNls9kZAJLE6Q3AnU0PFUp+EJZsoniw9E0mczQcoHe9zvjLI5Q3t8BVlBXg62DLlRhz+LNZb7gCxJb0c/kyqttRq2GvB0vLKqyJSsY+Vi/od6tRFN14rifyg6VlkB+i/KNXwJsqAp+cASjvELwJE880m82OTbKQDXdV9DOk2FSVPkChUBh7UDRmAiBtOIAa6QDj6QCoA1xexyUrxCGsV/S7dVKbD8J9EXYuu/GOH4T36Pp4AKC/Wo2P87nFoiTyFpEXfyt3Fn4UYAA5O60BtftJP5cvg/43YOR/0RA2Tdog+EH4DTi3a/QD0B4O0O9W8aaKiH7h1oqWaKvRTefZw8QfOA2GtnSwHcIAAAAASUVORK5CYII= diff --git a/pkg/octicons/octicons.go b/pkg/octicons/octicons.go index 5954a8c223..09e8f1275b 100644 --- a/pkg/octicons/octicons.go +++ b/pkg/octicons/octicons.go @@ -4,20 +4,46 @@ package octicons import ( "bufio" - "embed" - "encoding/base64" + _ "embed" "fmt" "strings" "github.com/modelcontextprotocol/go-sdk/mcp" ) -//go:embed icons/*.png -var iconsFS embed.FS - //go:embed required_icons.txt var requiredIconsTxt string +//go:embed icons_data_uris.txt +var embeddedDataURIs string + +type dataURIKey struct { + name string + theme Theme +} + +var dataURIs = loadDataURIs() + +func loadDataURIs() map[dataURIKey]string { + dataURIs := make(map[dataURIKey]string) + for line := range strings.SplitSeq(strings.TrimSpace(embeddedDataURIs), "\n") { + filename, dataURI, ok := strings.Cut(line, "\t") + separator := strings.LastIndexByte(filename, '-') + if !ok || separator <= 0 || !strings.HasPrefix(dataURI, "data:image/png;base64,") { + panic(fmt.Sprintf("invalid embedded icon data URI entry %q", line)) + } + theme := Theme(filename[separator+1:]) + if theme != ThemeLight && theme != ThemeDark { + panic(fmt.Sprintf("invalid embedded icon theme %q", theme)) + } + dataURIs[dataURIKey{ + name: filename[:separator], + theme: theme, + }] = dataURI + } + return dataURIs +} + // RequiredIcons returns the list of icon names from required_icons.txt. // This is the single source of truth for which icons should be embedded. func RequiredIcons() []string { @@ -48,14 +74,9 @@ const ( // The theme parameter specifies which variant to use: // - ThemeLight: dark icons for light backgrounds // - ThemeDark: light icons for dark backgrounds -// If the icon is not found in the embedded filesystem, it returns an empty string. +// If the icon is not found in the embedded icon set, it returns an empty string. func DataURI(name string, theme Theme) string { - filename := fmt.Sprintf("icons/%s-%s.png", name, theme) - data, err := iconsFS.ReadFile(filename) - if err != nil { - return "" - } - return "data:image/png;base64," + base64.StdEncoding.EncodeToString(data) + return dataURIs[dataURIKey{name: name, theme: theme}] } // Icons returns MCP Icon objects for the given octicon name in light and dark themes. diff --git a/pkg/octicons/octicons_benchmark_test.go b/pkg/octicons/octicons_benchmark_test.go new file mode 100644 index 0000000000..3c3ac10518 --- /dev/null +++ b/pkg/octicons/octicons_benchmark_test.go @@ -0,0 +1,39 @@ +package octicons + +import ( + "runtime" + "testing" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +var benchmarkDataURISink string +var benchmarkIconsSink [][]mcp.Icon + +func BenchmarkDataURI(b *testing.B) { + b.ReportAllocs() + for b.Loop() { + benchmarkDataURISink = DataURI("repo", ThemeLight) + } +} + +func BenchmarkIconsRegistration(b *testing.B) { + inventories := map[string][]string{ + "narrow": {"repo"}, + "default": RequiredIcons(), + } + for name, inventory := range inventories { + b.Run(name, func(b *testing.B) { + batch := make([][]mcp.Icon, len(inventory)) + b.ReportAllocs() + for b.Loop() { + for index, icon := range inventory { + batch[index] = Icons(icon) + } + } + b.StopTimer() + benchmarkIconsSink = batch + runtime.KeepAlive(batch) + }) + } +} diff --git a/pkg/octicons/octicons_test.go b/pkg/octicons/octicons_test.go index 078eb744f2..7d9c2e99d0 100644 --- a/pkg/octicons/octicons_test.go +++ b/pkg/octicons/octicons_test.go @@ -1,13 +1,20 @@ package octicons import ( + "embed" + "encoding/base64" + "io/fs" "strings" "testing" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) +//go:embed icons/*.png +var iconPNGs embed.FS + func TestDataURI(t *testing.T) { tests := []struct { name string @@ -37,6 +44,13 @@ func TestDataURI(t *testing.T) { wantDataURI: false, wantEmpty: true, }, + { + name: "unknown theme returns empty string", + icon: "repo", + theme: Theme("unknown"), + wantDataURI: false, + wantEmpty: true, + }, } for _, tc := range tests { @@ -53,6 +67,36 @@ func TestDataURI(t *testing.T) { } } +func TestDataURIForEveryEmbeddedIcon(t *testing.T) { + paths, err := fs.Glob(iconPNGs, "icons/*.png") + require.NoError(t, err) + require.NotEmpty(t, paths) + + for _, path := range paths { + filename := strings.TrimSuffix(strings.TrimPrefix(path, "icons/"), ".png") + separator := strings.LastIndexByte(filename, '-') + if separator <= 0 { + t.Errorf("cannot parse embedded icon path %q", path) + continue + } + name := filename[:separator] + theme := Theme(filename[separator+1:]) + t.Run(filename, func(t *testing.T) { + png, err := iconPNGs.ReadFile(path) + require.NoError(t, err) + + dataURI := DataURI(name, theme) + require.True(t, strings.HasPrefix(dataURI, "data:image/png;base64,")) + encodedPNG := strings.TrimPrefix(dataURI, "data:image/png;base64,") + decodedPNG, err := base64.StdEncoding.DecodeString(encodedPNG) + require.NoError(t, err) + assert.Equal(t, png, decodedPNG) + }) + } + + assert.Len(t, dataURIs, len(paths)) +} + func TestIcons(t *testing.T) { tests := []struct { name string diff --git a/script/fetch-icons b/script/fetch-icons index 21de625f17..f2cd381985 100755 --- a/script/fetch-icons +++ b/script/fetch-icons @@ -1,8 +1,8 @@ #!/bin/bash -# Fetch Octicon icons and convert them to PNG for embedding in the MCP server. +# Fetch Octicon icons and convert them to PNG and embedded data URIs. # Generates both light theme (dark icons) and dark theme (white icons) variants. # Uses sed to modify SVG fill color before converting to PNG. -# Requires: rsvg-convert (from librsvg2-bin on Ubuntu/Debian) +# Requires: rsvg-convert (from librsvg2-bin on Ubuntu/Debian), base64 # # Usage: # script/fetch-icons # Fetch all required icons @@ -13,6 +13,7 @@ set -e SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" ICONS_DIR="$REPO_ROOT/pkg/octicons/icons" +DATA_URIS_FILE="$REPO_ROOT/pkg/octicons/icons_data_uris.txt" REQUIRED_ICONS_FILE="$REPO_ROOT/pkg/octicons/required_icons.txt" OCTICONS_BASE="https://raw.githubusercontent.com/primer/octicons/main/icons" @@ -64,9 +65,20 @@ for icon in "${ICONS[@]}"; do echo "$dark_svg" | rsvg-convert -o "$dark_file" done +data_uris_tmp=$(mktemp) +trap 'rm -f "$data_uris_tmp"' EXIT +for icon_file in "$ICONS_DIR"/*.png; do + filename=$(basename "$icon_file" .png) + encoded_png=$(base64 < "$icon_file") + printf '%s\tdata:image/png;base64,%s\n' "$filename" "${encoded_png//$'\n'/}" >> "$data_uris_tmp" +done +mv "$data_uris_tmp" "$DATA_URIS_FILE" +trap - EXIT + echo "Done. Icons saved to $ICONS_DIR" +echo "Data URIs saved to $DATA_URIS_FILE" echo "" echo "Next steps:" echo " 1. Run 'go test ./pkg/octicons/...' to verify icons are embedded" echo " 2. Run 'go test ./pkg/github/...' to verify toolset icons are valid" -echo " 3. Commit the new icon files" +echo " 3. Commit the new icon files and generated data URIs" From 870f3c710a644b21e87118c06b1a8721fae3ca31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=9F=B3=E5=B2=B3=E5=B3=B0?= <132282304+syf2211@users.noreply.github.com> Date: Thu, 16 Jul 2026 22:39:04 +0800 Subject: [PATCH 18/35] fix(labels): add DestructiveHint to label_write tool (#2763) --- pkg/github/__toolsnaps__/label_write.snap | 1 + pkg/github/labels.go | 5 +++-- pkg/github/labels_test.go | 2 ++ 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/pkg/github/__toolsnaps__/label_write.snap b/pkg/github/__toolsnaps__/label_write.snap index 6eeb9fd730..e9fdcf0d83 100644 --- a/pkg/github/__toolsnaps__/label_write.snap +++ b/pkg/github/__toolsnaps__/label_write.snap @@ -1,5 +1,6 @@ { "annotations": { + "destructiveHint": true, "idempotentHint": false, "readOnlyHint": false, "title": "Write operations on repository labels" diff --git a/pkg/github/labels.go b/pkg/github/labels.go index 0e49968496..29ae3d5323 100644 --- a/pkg/github/labels.go +++ b/pkg/github/labels.go @@ -226,8 +226,9 @@ func LabelWrite(t translations.TranslationHelperFunc) inventory.ServerTool { Name: "label_write", Description: t("TOOL_LABEL_WRITE_DESCRIPTION", "Perform write operations on repository labels. To set labels on issues, use the 'update_issue' tool."), Annotations: &mcp.ToolAnnotations{ - Title: t("TOOL_LABEL_WRITE_TITLE", "Write operations on repository labels"), - ReadOnlyHint: false, + Title: t("TOOL_LABEL_WRITE_TITLE", "Write operations on repository labels"), + ReadOnlyHint: false, + DestructiveHint: jsonschema.Ptr(true), }, InputSchema: &jsonschema.Schema{ Type: "object", diff --git a/pkg/github/labels_test.go b/pkg/github/labels_test.go index 88102ba3c9..c3434b240b 100644 --- a/pkg/github/labels_test.go +++ b/pkg/github/labels_test.go @@ -247,6 +247,8 @@ func TestWriteLabel(t *testing.T) { assert.Equal(t, "label_write", tool.Name) assert.NotEmpty(t, tool.Description) assert.False(t, tool.Annotations.ReadOnlyHint, "label_write tool should not be read-only") + assert.NotNil(t, tool.Annotations.DestructiveHint) + assert.True(t, *tool.Annotations.DestructiveHint, "label_write delete removes labels repository-wide") tests := []struct { name string From 1338dbed4a044ee26422d4212bac3a8037fdb7ff Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Sat, 18 Jul 2026 23:21:55 +0200 Subject: [PATCH 19/35] build(deps): bump go-sdk to 1.7.0-pre.3 (#2907) Adopt the pre.3 protocol correctness fixes without changing server wiring or MCP tool schemas. Update exact SDK license references for all release platforms. Copilot-Session: f399f533-3d1f-4c76-872b-f9813729a61f Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- go.mod | 2 +- go.sum | 4 ++-- third-party-licenses.darwin.md | 4 ++-- third-party-licenses.linux.md | 4 ++-- third-party-licenses.windows.md | 4 ++-- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/go.mod b/go.mod index d553a10509..a6aff1bbe6 100644 --- a/go.mod +++ b/go.mod @@ -10,7 +10,7 @@ require ( github.com/josephburnett/jd/v2 v2.5.0 github.com/lithammer/fuzzysearch v1.1.8 github.com/microcosm-cc/bluemonday v1.0.27 - github.com/modelcontextprotocol/go-sdk v1.7.0-pre.2 + github.com/modelcontextprotocol/go-sdk v1.7.0-pre.3 github.com/muesli/cache2go v0.0.0-20221011235721-518229cd8021 github.com/shurcooL/githubv4 v0.0.0-20240727222349-48295856cce7 github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466 diff --git a/go.sum b/go.sum index 4aa9805e04..76e5a771ec 100644 --- a/go.sum +++ b/go.sum @@ -39,8 +39,8 @@ github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8 github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= -github.com/modelcontextprotocol/go-sdk v1.7.0-pre.2 h1:3JwUps1pdSpXYndBMGO9SMca6CSkP9AKnOaKAkSSGHc= -github.com/modelcontextprotocol/go-sdk v1.7.0-pre.2/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts= +github.com/modelcontextprotocol/go-sdk v1.7.0-pre.3 h1:SEAY9IduDif4iApnZgpFkjFIdo3askSGZVbZIYyTy6I= +github.com/modelcontextprotocol/go-sdk v1.7.0-pre.3/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts= github.com/muesli/cache2go v0.0.0-20221011235721-518229cd8021 h1:31Y+Yu373ymebRdJN1cWLLooHH8xAr0MhKTEJGV/87g= github.com/muesli/cache2go v0.0.0-20221011235721-518229cd8021/go.mod h1:WERUkUryfUWlrHnFSO/BEUZ+7Ns8aZy7iVOGewxKzcc= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= diff --git a/third-party-licenses.darwin.md b/third-party-licenses.darwin.md index 111bf76d7d..3e3be90704 100644 --- a/third-party-licenses.darwin.md +++ b/third-party-licenses.darwin.md @@ -24,8 +24,8 @@ The following packages are included for the amd64, arm64 architectures. - [github.com/josephburnett/jd/v2](https://pkg.go.dev/github.com/josephburnett/jd/v2) ([MIT](https://github.com/josephburnett/jd/blob/v2.5.0/v2/LICENSE)) - [github.com/lithammer/fuzzysearch/fuzzy](https://pkg.go.dev/github.com/lithammer/fuzzysearch/fuzzy) ([MIT](https://github.com/lithammer/fuzzysearch/blob/v1.1.8/LICENSE)) - [github.com/microcosm-cc/bluemonday](https://pkg.go.dev/github.com/microcosm-cc/bluemonday) ([BSD-3-Clause](https://github.com/microcosm-cc/bluemonday/blob/v1.0.27/LICENSE.md)) - - [github.com/modelcontextprotocol/go-sdk](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk) ([Apache-2.0](https://github.com/modelcontextprotocol/go-sdk/blob/v1.7.0-pre.2/LICENSE)) - - [github.com/modelcontextprotocol/go-sdk](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk) ([MIT](https://github.com/modelcontextprotocol/go-sdk/blob/v1.7.0-pre.2/LICENSE)) + - [github.com/modelcontextprotocol/go-sdk](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk) ([Apache-2.0](https://github.com/modelcontextprotocol/go-sdk/blob/v1.7.0-pre.3/LICENSE)) + - [github.com/modelcontextprotocol/go-sdk](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk) ([MIT](https://github.com/modelcontextprotocol/go-sdk/blob/v1.7.0-pre.3/LICENSE)) - [github.com/muesli/cache2go](https://pkg.go.dev/github.com/muesli/cache2go) ([BSD-3-Clause](https://github.com/muesli/cache2go/blob/518229cd8021/LICENSE.txt)) - [github.com/pelletier/go-toml/v2](https://pkg.go.dev/github.com/pelletier/go-toml/v2) ([MIT](https://github.com/pelletier/go-toml/blob/v2.2.4/LICENSE)) - [github.com/sagikazarmark/locafero](https://pkg.go.dev/github.com/sagikazarmark/locafero) ([MIT](https://github.com/sagikazarmark/locafero/blob/v0.11.0/LICENSE)) diff --git a/third-party-licenses.linux.md b/third-party-licenses.linux.md index 2e76b2885f..e686da2d83 100644 --- a/third-party-licenses.linux.md +++ b/third-party-licenses.linux.md @@ -24,8 +24,8 @@ The following packages are included for the 386, amd64, arm64 architectures. - [github.com/josephburnett/jd/v2](https://pkg.go.dev/github.com/josephburnett/jd/v2) ([MIT](https://github.com/josephburnett/jd/blob/v2.5.0/v2/LICENSE)) - [github.com/lithammer/fuzzysearch/fuzzy](https://pkg.go.dev/github.com/lithammer/fuzzysearch/fuzzy) ([MIT](https://github.com/lithammer/fuzzysearch/blob/v1.1.8/LICENSE)) - [github.com/microcosm-cc/bluemonday](https://pkg.go.dev/github.com/microcosm-cc/bluemonday) ([BSD-3-Clause](https://github.com/microcosm-cc/bluemonday/blob/v1.0.27/LICENSE.md)) - - [github.com/modelcontextprotocol/go-sdk](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk) ([Apache-2.0](https://github.com/modelcontextprotocol/go-sdk/blob/v1.7.0-pre.2/LICENSE)) - - [github.com/modelcontextprotocol/go-sdk](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk) ([MIT](https://github.com/modelcontextprotocol/go-sdk/blob/v1.7.0-pre.2/LICENSE)) + - [github.com/modelcontextprotocol/go-sdk](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk) ([Apache-2.0](https://github.com/modelcontextprotocol/go-sdk/blob/v1.7.0-pre.3/LICENSE)) + - [github.com/modelcontextprotocol/go-sdk](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk) ([MIT](https://github.com/modelcontextprotocol/go-sdk/blob/v1.7.0-pre.3/LICENSE)) - [github.com/muesli/cache2go](https://pkg.go.dev/github.com/muesli/cache2go) ([BSD-3-Clause](https://github.com/muesli/cache2go/blob/518229cd8021/LICENSE.txt)) - [github.com/pelletier/go-toml/v2](https://pkg.go.dev/github.com/pelletier/go-toml/v2) ([MIT](https://github.com/pelletier/go-toml/blob/v2.2.4/LICENSE)) - [github.com/sagikazarmark/locafero](https://pkg.go.dev/github.com/sagikazarmark/locafero) ([MIT](https://github.com/sagikazarmark/locafero/blob/v0.11.0/LICENSE)) diff --git a/third-party-licenses.windows.md b/third-party-licenses.windows.md index 4c66b80842..fc15be2de6 100644 --- a/third-party-licenses.windows.md +++ b/third-party-licenses.windows.md @@ -25,8 +25,8 @@ The following packages are included for the 386, amd64, arm64 architectures. - [github.com/josephburnett/jd/v2](https://pkg.go.dev/github.com/josephburnett/jd/v2) ([MIT](https://github.com/josephburnett/jd/blob/v2.5.0/v2/LICENSE)) - [github.com/lithammer/fuzzysearch/fuzzy](https://pkg.go.dev/github.com/lithammer/fuzzysearch/fuzzy) ([MIT](https://github.com/lithammer/fuzzysearch/blob/v1.1.8/LICENSE)) - [github.com/microcosm-cc/bluemonday](https://pkg.go.dev/github.com/microcosm-cc/bluemonday) ([BSD-3-Clause](https://github.com/microcosm-cc/bluemonday/blob/v1.0.27/LICENSE.md)) - - [github.com/modelcontextprotocol/go-sdk](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk) ([Apache-2.0](https://github.com/modelcontextprotocol/go-sdk/blob/v1.7.0-pre.2/LICENSE)) - - [github.com/modelcontextprotocol/go-sdk](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk) ([MIT](https://github.com/modelcontextprotocol/go-sdk/blob/v1.7.0-pre.2/LICENSE)) + - [github.com/modelcontextprotocol/go-sdk](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk) ([Apache-2.0](https://github.com/modelcontextprotocol/go-sdk/blob/v1.7.0-pre.3/LICENSE)) + - [github.com/modelcontextprotocol/go-sdk](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk) ([MIT](https://github.com/modelcontextprotocol/go-sdk/blob/v1.7.0-pre.3/LICENSE)) - [github.com/muesli/cache2go](https://pkg.go.dev/github.com/muesli/cache2go) ([BSD-3-Clause](https://github.com/muesli/cache2go/blob/518229cd8021/LICENSE.txt)) - [github.com/pelletier/go-toml/v2](https://pkg.go.dev/github.com/pelletier/go-toml/v2) ([MIT](https://github.com/pelletier/go-toml/blob/v2.2.4/LICENSE)) - [github.com/sagikazarmark/locafero](https://pkg.go.dev/github.com/sagikazarmark/locafero) ([MIT](https://github.com/sagikazarmark/locafero/blob/v0.11.0/LICENSE)) From 9184f777bdcb7c6b60ca5a05a8585954e1686b02 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 20 Jul 2026 16:23:47 +0000 Subject: [PATCH 20/35] build(deps): bump distroless/base-debian12 from `9c05cfd` to `348dac1` Bumps distroless/base-debian12 from `9c05cfd` to `348dac1`. --- updated-dependencies: - dependency-name: distroless/base-debian12 dependency-version: 348dac1808083ccc3366399d6db835875b4eaf7c9b694783f5a3f353c4b58a28 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 0568f827e5..cb2ce8df12 100644 --- a/Dockerfile +++ b/Dockerfile @@ -35,7 +35,7 @@ RUN --mount=type=cache,target=/go/pkg/mod \ -o /bin/github-mcp-server ./cmd/github-mcp-server # Make a stage to run the app -FROM gcr.io/distroless/base-debian12@sha256:9c05cfd65f41c93a909ea67eb05b920a3b838780ea55df5421d48295d98ff957 +FROM gcr.io/distroless/base-debian12@sha256:348dac1808083ccc3366399d6db835875b4eaf7c9b694783f5a3f353c4b58a28 # Add required MCP server annotation LABEL io.modelcontextprotocol.server.name="io.github.github/github-mcp-server" From 6a44cf24af97d8858754141bbf2fa05c40413b40 Mon Sep 17 00:00:00 2001 From: Bryan Zwicker Date: Tue, 21 Jul 2026 04:25:24 -0400 Subject: [PATCH 21/35] Paginate project item lookup (#2914) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1a67631b-dc18-448a-8be5-81110bfd543a Co-authored-by: Ross Tarrant --- pkg/github/projects_resolver.go | 56 +++++--- pkg/github/projects_resolver_test.go | 204 ++++++++++++++++++++++++++- 2 files changed, 242 insertions(+), 18 deletions(-) diff --git a/pkg/github/projects_resolver.go b/pkg/github/projects_resolver.go index a5c93b5b93..25a690888f 100644 --- a/pkg/github/projects_resolver.go +++ b/pkg/github/projects_resolver.go @@ -271,18 +271,20 @@ func resolveProjectItemIDByIssueNumber(ctx context.Context, gqlClient *githubv4. return 0, err } - var query struct { + type projectItemsConnection struct { + Nodes []struct { + FullDatabaseID githubv4.String `graphql:"fullDatabaseId"` + Project struct { + ID githubv4.ID + } + } + PageInfo PageInfoFragment + } + + var firstPageQuery struct { Repository struct { Issue struct { - ProjectItems struct { - Nodes []struct { - FullDatabaseID githubv4.String `graphql:"fullDatabaseId"` - Project struct { - ID githubv4.ID - } - } - PageInfo PageInfoFragment - } `graphql:"projectItems(first: 50, includeArchived: true)"` + ProjectItems projectItemsConnection `graphql:"projectItems(first: 50, includeArchived: true)"` } `graphql:"issue(number: $issueNumber)"` } `graphql:"repository(owner: $issueOwner, name: $issueRepo)"` } @@ -293,18 +295,38 @@ func resolveProjectItemIDByIssueNumber(ctx context.Context, gqlClient *githubv4. "issueNumber": githubv4.Int(int32(issueNumber)), //nolint:gosec // Issue numbers are small } - if err := gqlClient.Query(ctx, &query, vars); err != nil { + if err := gqlClient.Query(ctx, &firstPageQuery, vars); err != nil { return 0, fmt.Errorf("failed to resolve project item for %s/%s#%d: %w", issueOwner, issueRepo, issueNumber, err) } - for _, item := range query.Repository.Issue.ProjectItems.Nodes { - if item.Project.ID == projectID { - itemID, parseErr := parseInt64(string(item.FullDatabaseID)) - if parseErr != nil { - return 0, fmt.Errorf("project item ID %q is not an integer: %w", string(item.FullDatabaseID), parseErr) + projectItems := firstPageQuery.Repository.Issue.ProjectItems + for { + for _, item := range projectItems.Nodes { + if item.Project.ID == projectID { + itemID, parseErr := parseInt64(string(item.FullDatabaseID)) + if parseErr != nil { + return 0, fmt.Errorf("project item ID %q is not an integer: %w", string(item.FullDatabaseID), parseErr) + } + return itemID, nil } - return itemID, nil } + + if !projectItems.PageInfo.HasNextPage { + break + } + + var nextPageQuery struct { + Repository struct { + Issue struct { + ProjectItems projectItemsConnection `graphql:"projectItems(first: 50, after: $after, includeArchived: true)"` + } `graphql:"issue(number: $issueNumber)"` + } `graphql:"repository(owner: $issueOwner, name: $issueRepo)"` + } + vars["after"] = projectItems.PageInfo.EndCursor + if err := gqlClient.Query(ctx, &nextPageQuery, vars); err != nil { + return 0, fmt.Errorf("failed to resolve project item for %s/%s#%d: %w", issueOwner, issueRepo, issueNumber, err) + } + projectItems = nextPageQuery.Repository.Issue.ProjectItems } return 0, ghErrors.NewStructuredResolutionError( diff --git a/pkg/github/projects_resolver_test.go b/pkg/github/projects_resolver_test.go index e701441c1e..5b563d2fa1 100644 --- a/pkg/github/projects_resolver_test.go +++ b/pkg/github/projects_resolver_test.go @@ -215,6 +215,32 @@ type resolveItemByIssueQuery struct { } `graphql:"repository(owner: $issueOwner, name: $issueRepo)"` } +type resolveItemByIssuePageQuery struct { + Repository struct { + Issue struct { + ProjectItems struct { + Nodes []struct { + FullDatabaseID githubv4.String `graphql:"fullDatabaseId"` + Project struct { + ID githubv4.ID + } + } + PageInfo PageInfoFragment + } `graphql:"projectItems(first: 50, after: $after, includeArchived: true)"` + } `graphql:"issue(number: $issueNumber)"` + } `graphql:"repository(owner: $issueOwner, name: $issueRepo)"` +} + +type requestCountingTransport struct { + inner http.RoundTripper + count int +} + +func (t *requestCountingTransport) RoundTrip(req *http.Request) (*http.Response, error) { + t.count++ + return t.inner.RoundTrip(req) +} + func Test_ResolveProjectItemIDByIssueNumber_Success(t *testing.T) { mocked := githubv4mock.NewMockedHTTPClient( // project node id lookup (org) @@ -279,6 +305,91 @@ func Test_ResolveProjectItemIDByIssueNumber_Success(t *testing.T) { assert.Equal(t, int64(4242), itemID) } +func Test_ResolveProjectItemIDByIssueNumber_TargetOnSecondPage(t *testing.T) { + mocked := githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + struct { + Organization struct { + ProjectV2 struct { + ID githubv4.ID + } `graphql:"projectV2(number: $projectNumber)"` + } `graphql:"organization(login: $owner)"` + }{}, + map[string]any{ + "owner": githubv4.String("octo-org"), + "projectNumber": githubv4.Int(1), + }, + githubv4mock.DataResponse(map[string]any{ + "organization": map[string]any{ + "projectV2": map[string]any{"id": "PVT_project1"}, + }, + }), + ), + githubv4mock.NewQueryMatcher( + resolveItemByIssueQuery{}, + map[string]any{ + "issueOwner": githubv4.String("octo-issue-owner"), + "issueRepo": githubv4.String("repo"), + "issueNumber": githubv4.Int(123), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "issue": map[string]any{ + "projectItems": map[string]any{ + "nodes": []any{ + map[string]any{ + "fullDatabaseId": "9999", + "project": map[string]any{"id": "PVT_other"}, + }, + }, + "pageInfo": map[string]any{ + "hasNextPage": true, + "hasPreviousPage": false, + "startCursor": "first", + "endCursor": "page-one", + }, + }, + }, + }, + }), + ), + githubv4mock.NewQueryMatcher( + resolveItemByIssuePageQuery{}, + map[string]any{ + "issueOwner": githubv4.String("octo-issue-owner"), + "issueRepo": githubv4.String("repo"), + "issueNumber": githubv4.Int(123), + "after": githubv4.String("page-one"), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "issue": map[string]any{ + "projectItems": map[string]any{ + "nodes": []any{ + map[string]any{ + "fullDatabaseId": "4242", + "project": map[string]any{"id": "PVT_project1"}, + }, + }, + "pageInfo": map[string]any{ + "hasNextPage": false, + "hasPreviousPage": true, + "startCursor": "page-two", + "endCursor": "page-two", + }, + }, + }, + }, + }), + ), + ) + gql := githubv4.NewClient(mocked) + + itemID, err := resolveProjectItemIDByIssueNumber(context.Background(), gql, "octo-org", "org", 1, "octo-issue-owner", "repo", 123) + require.NoError(t, err) + assert.Equal(t, int64(4242), itemID) +} + func Test_ResolveProjectItemIDByIssueNumber_NotInProject(t *testing.T) { mocked := githubv4mock.NewMockedHTTPClient( githubv4mock.NewQueryMatcher( @@ -340,6 +451,97 @@ func Test_ResolveProjectItemIDByIssueNumber_NotInProject(t *testing.T) { assert.Equal(t, "item_not_in_project", msg["error"]) } +func Test_ResolveProjectItemIDByIssueNumber_NotInProjectAfterMultiplePages(t *testing.T) { + mocked := githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + struct { + Organization struct { + ProjectV2 struct { + ID githubv4.ID + } `graphql:"projectV2(number: $projectNumber)"` + } `graphql:"organization(login: $owner)"` + }{}, + map[string]any{ + "owner": githubv4.String("octo-org"), + "projectNumber": githubv4.Int(1), + }, + githubv4mock.DataResponse(map[string]any{ + "organization": map[string]any{ + "projectV2": map[string]any{"id": "PVT_project1"}, + }, + }), + ), + githubv4mock.NewQueryMatcher( + resolveItemByIssueQuery{}, + map[string]any{ + "issueOwner": githubv4.String("octo-issue-owner"), + "issueRepo": githubv4.String("repo"), + "issueNumber": githubv4.Int(123), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "issue": map[string]any{ + "projectItems": map[string]any{ + "nodes": []any{ + map[string]any{ + "fullDatabaseId": "9999", + "project": map[string]any{"id": "PVT_other"}, + }, + }, + "pageInfo": map[string]any{ + "hasNextPage": true, + "hasPreviousPage": false, + "startCursor": "first", + "endCursor": "page-one", + }, + }, + }, + }, + }), + ), + githubv4mock.NewQueryMatcher( + resolveItemByIssuePageQuery{}, + map[string]any{ + "issueOwner": githubv4.String("octo-issue-owner"), + "issueRepo": githubv4.String("repo"), + "issueNumber": githubv4.Int(123), + "after": githubv4.String("page-one"), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "issue": map[string]any{ + "projectItems": map[string]any{ + "nodes": []any{ + map[string]any{ + "fullDatabaseId": "8888", + "project": map[string]any{"id": "PVT_another"}, + }, + }, + "pageInfo": map[string]any{ + "hasNextPage": false, + "hasPreviousPage": true, + "startCursor": "page-two", + "endCursor": "page-two", + }, + }, + }, + }, + }), + ), + ) + countingTransport := &requestCountingTransport{inner: mocked.Transport} + mocked.Transport = countingTransport + gql := githubv4.NewClient(mocked) + + _, err := resolveProjectItemIDByIssueNumber(context.Background(), gql, "octo-org", "org", 1, "octo-issue-owner", "repo", 123) + require.Error(t, err) + assert.Equal(t, 3, countingTransport.count) + + var msg map[string]any + require.NoError(t, json.Unmarshal([]byte(err.Error()), &msg)) + assert.Equal(t, "item_not_in_project", msg["error"]) +} + func Test_ResolveFieldNamesToIDs_Success(t *testing.T) { mocked := githubv4mock.NewMockedHTTPClient( githubv4mock.NewQueryMatcher( @@ -358,7 +560,7 @@ func Test_ResolveFieldNamesToIDs_Success(t *testing.T) { assert.Equal(t, []int64{100, 200}, ids) } -// Field and single-select option name matching is case-insensitive so agents passing lowercase +// Field and single-select option name matching is case-insensitive so agents passing lowercase // names like "status" or "in progress" resolve to "Status" and "In Progress" respectively. func Test_ResolveProjectFieldByName_CaseInsensitive(t *testing.T) { mocked := githubv4mock.NewMockedHTTPClient( From 9d130049e9074772c2afbbd5e904725d240443ad Mon Sep 17 00:00:00 2001 From: Boaz Reicher <44614829+boazreicher@users.noreply.github.com> Date: Tue, 21 Jul 2026 12:27:09 +0300 Subject: [PATCH 22/35] Add opt-in intent-aware Copilot issue assignment tool (#2909) * Add opt-in intent-aware Copilot issue assignment tool Add `assign_copilot_to_issue_with_intent` in a new non-default toolset `copilot_issue_intents`. The tool uses GraphQL's object-form `assignees: [AssigneeUpdateInput!]` so intent metadata (rationale, confidence, is_suggestion) is attached to the Copilot entry only, while existing assignees are preserved. - Reuses the existing Copilot actor lookup, target-repository resolution, base_ref, custom_instructions, GraphQL-Features header, and direct-assignment PR polling behavior. - `is_suggestion: true` records a pending Copilot assignment intent, returns a suggestion-shaped result, and does not launch Copilot or poll for a linked PR. - `rationale` is capped at 280 characters (schema + runtime); `confidence` is validated against `LOW`/`MEDIUM`/`HIGH`. - Toolset is non-default so its inputs do not add schema bloat to the default tool surface; available via `copilot_issue_intents`, `all`, or explicit tool selection. Includes unit tests for direct assignment (with existing assignees and with base_ref/custom_instructions), the suggestion path, invalid rationale length, invalid confidence, and Copilot-not-available; a generated toolsnap; regenerated docs; and an e2e test for the suggestion path. Refs: github/plan-track-agentic-toolkit#683 * Address review: tighten description, require intent fields Per review feedback on #2909: - Trim the tool description to mirror assign_copilot_to_issue and add "Prefer this tool over assign_copilot_to_issue when available", removing the verbose is_suggestion narrative from the schema. - Make rationale, confidence, and is_suggestion required inputs (schema and runtime). is_suggestion is now always sent explicitly on the Copilot AssigneeUpdateInput entry. - Update unit tests to supply the newly-required fields and cover the missing-rationale and missing-confidence rejection paths. - Regenerate toolsnap and README. * Address review: dedupe copilot, require is_suggestion, update doc link Per @RossTarrant review feedback on #2909: - Reject requests where `is_suggestion` is omitted from the raw args before decoding. `mapstructure.WeakDecode` defaults missing bools to false, which would silently launch Copilot instead of recording a suggestion. Presence-check the raw map so callers make the choice explicit. - Skip the copilot-swe-agent actor when copying existing assignees so we don't send its actorId twice (once without metadata and once with intent metadata) when Copilot is already assigned. - Update the stale about-assigning-tasks-to-copilot reference to the redirect target (about-cloud-agent). Applied to the const message, both tool descriptions, and the e2e/unit-test literals that assert on that message. New unit tests cover the missing-is_suggestion rejection and the copilot-dedup behavior. Regenerated toolsnaps. --- README.md | 18 + docs/remote-server.md | 1 + e2e/e2e_test.go | 111 ++++- .../assign_copilot_to_issue.snap | 2 +- .../assign_copilot_to_issue_with_intent.snap | 72 +++ pkg/github/copilot.go | 384 ++++++++++++++- pkg/github/copilot_test.go | 450 +++++++++++++++++- pkg/github/tools.go | 13 + 8 files changed, 1044 insertions(+), 7 deletions(-) create mode 100644 pkg/github/__toolsnaps__/assign_copilot_to_issue_with_intent.snap diff --git a/README.md b/README.md index 10d987a262..9a6ef3677a 100644 --- a/README.md +++ b/README.md @@ -590,6 +590,7 @@ The following sets of tools are available: | code-square | `code_quality` | GitHub Code Quality related tools | | codescan | `code_security` | Code security related tools, such as GitHub Code Scanning | | copilot | `copilot` | Copilot related tools | +| copilot | `copilot_issue_intents` | Opt-in Copilot issue assignment tools that carry intent metadata (rationale, confidence, suggestion) | | dependabot | `dependabot` | Dependabot tools | | comment-discussion | `discussions` | GitHub Discussions related tools | | logo-gist | `gists` | GitHub Gist related tools | @@ -750,6 +751,23 @@ The following sets of tools are available:
+copilot Copilot Issue Intents + +- **assign_copilot_to_issue_with_intent** - Assign Copilot to issue with intent + - **Required OAuth Scopes**: `repo` + - `base_ref`: Git reference (e.g., branch) that the agent will start its work from. If not specified, defaults to the repository's default branch. Ignored when is_suggestion is true (string, optional) + - `confidence`: How confident you are in this choice. 'HIGH' for clear signal or explicit user request, 'MEDIUM' for reasonable inference with some ambiguity, 'LOW' for best guess with limited signal. (string, required) + - `custom_instructions`: Optional custom instructions to guide the agent beyond the issue body. Ignored when is_suggestion is true (string, optional) + - `is_suggestion`: If true, records a pending Copilot assignment intent rather than launching the agent. Approval later supplies the launch context; base_ref and custom_instructions are ignored in this case. (boolean, required) + - `issue_number`: Issue number (number, required) + - `owner`: Repository owner (string, required) + - `rationale`: One concise sentence explaining what specifically about the issue led to choosing Copilot. State the concrete signal (e.g. 'Well-scoped task with clear acceptance criteria'). (string, required) + - `repo`: Repository name (string, required) + +
+ +
+ dependabot Dependabot - **get_dependabot_alert** - Get dependabot alert diff --git a/docs/remote-server.md b/docs/remote-server.md index 4665ba8044..04d3ceefae 100644 --- a/docs/remote-server.md +++ b/docs/remote-server.md @@ -25,6 +25,7 @@ Below is a table of available toolsets for the remote GitHub MCP Server. Each to | code-square
`code_quality` | GitHub Code Quality related tools | https://api.githubcopilot.com/mcp/x/code_quality | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_quality&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcode_quality%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/code_quality/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_quality&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcode_quality%2Freadonly%22%7D) | | codescan
`code_security` | Code security related tools, such as GitHub Code Scanning | https://api.githubcopilot.com/mcp/x/code_security | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_security&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcode_security%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/code_security/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_security&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcode_security%2Freadonly%22%7D) | | copilot
`copilot` | Copilot related tools | https://api.githubcopilot.com/mcp/x/copilot | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-copilot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcopilot%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/copilot/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-copilot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcopilot%2Freadonly%22%7D) | +| copilot
`copilot_issue_intents` | Opt-in Copilot issue assignment tools that carry intent metadata (rationale, confidence, suggestion) | https://api.githubcopilot.com/mcp/x/copilot_issue_intents | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-copilot_issue_intents&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcopilot_issue_intents%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/copilot_issue_intents/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-copilot_issue_intents&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcopilot_issue_intents%2Freadonly%22%7D) | | dependabot
`dependabot` | Dependabot tools | https://api.githubcopilot.com/mcp/x/dependabot | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-dependabot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdependabot%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/dependabot/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-dependabot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdependabot%2Freadonly%22%7D) | | comment-discussion
`discussions` | GitHub Discussions related tools | https://api.githubcopilot.com/mcp/x/discussions | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-discussions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdiscussions%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/discussions/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-discussions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdiscussions%2Freadonly%22%7D) | | logo-gist
`gists` | GitHub Gist related tools | https://api.githubcopilot.com/mcp/x/gists | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-gists&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgists%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/gists/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-gists&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgists%2Freadonly%22%7D) | diff --git a/e2e/e2e_test.go b/e2e/e2e_test.go index 4be9a45aa8..a094cada6a 100644 --- a/e2e/e2e_test.go +++ b/e2e/e2e_test.go @@ -1085,7 +1085,7 @@ func TestAssignCopilotToIssue(t *testing.T) { textContent, ok = resp.Content[0].(*mcp.TextContent) require.True(t, ok, "expected content to be of type TextContent") - possibleExpectedFailure := "copilot isn't available as an assignee for this issue. Please inform the user to visit https://docs.github.com/en/copilot/using-github-copilot/using-copilot-coding-agent-to-work-on-tasks/about-assigning-tasks-to-copilot for more information." + possibleExpectedFailure := "copilot isn't available as an assignee for this issue. Please inform the user to visit https://docs.github.com/en/copilot/concepts/agents/cloud-agent/about-cloud-agent for more information." if resp.IsError && textContent.Text == possibleExpectedFailure { t.Skip("skipping because copilot wasn't available as an assignee on this issue, it's likely that the owner doesn't have copilot enabled in their settings") } @@ -1104,6 +1104,115 @@ func TestAssignCopilotToIssue(t *testing.T) { require.Equal(t, "Copilot", *assignees.Assignees[0].Login, "expected copilot to be assigned to the issue") } +// TestAssignCopilotToIssueWithIntent exercises the opt-in intent-aware assignment +// tool along the is_suggestion=true path. That path records a pending Copilot +// assignment intent rather than launching the agent, so the tool returns a +// suggestion-shaped result without a linked pull request and no Copilot user is +// added to the issue's assignees. +func TestAssignCopilotToIssueWithIntent(t *testing.T) { + t.Parallel() + + if getE2EHost() != "" && getE2EHost() != "https://github.com" { + t.Skip("Skipping test because the host does not support copilot being assigned to issues") + } + + mcpClient := setupMCPClient(t) + ctx := context.Background() + + t.Log("Getting current user...") + resp, err := mcpClient.CallTool(ctx, &mcp.CallToolParams{Name: "get_me"}) + require.NoError(t, err, "expected to call 'get_me' tool successfully") + require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp)) + require.Len(t, resp.Content, 1, "expected content to have one item") + + textContent, ok := resp.Content[0].(*mcp.TextContent) + require.True(t, ok, "expected content to be of type TextContent") + + var trimmedGetMeText struct { + Login string `json:"login"` + } + err = json.Unmarshal([]byte(textContent.Text), &trimmedGetMeText) + require.NoError(t, err, "expected to unmarshal text content successfully") + currentOwner := trimmedGetMeText.Login + + repoName := fmt.Sprintf("github-mcp-server-e2e-%s-%d", t.Name(), time.Now().UnixMilli()) + + t.Logf("Creating repository %s/%s...", currentOwner, repoName) + _, err = mcpClient.CallTool(ctx, &mcp.CallToolParams{ + Name: "create_repository", + Arguments: map[string]any{ + "name": repoName, + "private": true, + "autoInit": true, + }, + }) + require.NoError(t, err, "expected to call 'create_repository' tool successfully") + + t.Cleanup(func() { + ghClient := getRESTClient(t) + t.Logf("Deleting repository %s/%s...", currentOwner, repoName) + _, err := ghClient.Repositories.Delete(context.Background(), currentOwner, repoName) + require.NoError(t, err, "expected to delete repository successfully") + }) + + t.Logf("Creating issue in %s/%s...", currentOwner, repoName) + resp, err = mcpClient.CallTool(ctx, &mcp.CallToolParams{ + Name: "issue_write", + Arguments: map[string]any{ + "method": "create", + "owner": currentOwner, + "repo": repoName, + "title": "Test issue for intent-aware copilot suggestion", + }, + }) + require.NoError(t, err, "expected to call 'issue_write' tool successfully") + require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp)) + + t.Logf("Recording pending copilot assignment suggestion in %s/%s...", currentOwner, repoName) + resp, err = mcpClient.CallTool(ctx, &mcp.CallToolParams{ + Name: "assign_copilot_to_issue_with_intent", + Arguments: map[string]any{ + "owner": currentOwner, + "repo": repoName, + "issue_number": 1, + "rationale": "E2E: well-scoped test task.", + "confidence": "HIGH", + "is_suggestion": true, + }, + }) + require.NoError(t, err, "expected to call 'assign_copilot_to_issue_with_intent' tool successfully") + + require.Len(t, resp.Content, 1, "expected content to have one item") + textContent, ok = resp.Content[0].(*mcp.TextContent) + require.True(t, ok, "expected content to be of type TextContent") + + possibleExpectedFailure := "copilot isn't available as an assignee for this issue. Please inform the user to visit https://docs.github.com/en/copilot/concepts/agents/cloud-agent/about-cloud-agent for more information." + if resp.IsError && textContent.Text == possibleExpectedFailure { + t.Skip("skipping because copilot wasn't available as an assignee on this issue, it's likely that the owner doesn't have copilot enabled in their settings") + } + + require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp)) + + var response map[string]any + require.NoError(t, json.Unmarshal([]byte(textContent.Text), &response), "expected suggestion result to be JSON") + require.Equal(t, true, response["is_suggestion"], "expected is_suggestion=true in result") + require.Contains(t, response["message"], "pending copilot assignment suggestion", + "expected suggestion-shaped message, got %v", response["message"]) + require.NotContains(t, response, "pull_request", + "suggestion path must not claim PR creation") + + // A pure suggestion does not launch Copilot, so no Copilot user should appear + // on the issue's assignees list. + ghClient := getRESTClient(t) + issue, response2, err := ghClient.Issues.Get(context.Background(), currentOwner, repoName, 1) + require.NoError(t, err, "expected to get issue successfully") + require.Equal(t, http.StatusOK, response2.StatusCode, "expected to get issue successfully") + for _, a := range issue.Assignees { + require.NotEqual(t, "Copilot", *a.Login, + "suggestion path must not add Copilot to applied assignees") + } +} + func TestPullRequestAtomicCreateAndSubmit(t *testing.T) { t.Parallel() diff --git a/pkg/github/__toolsnaps__/assign_copilot_to_issue.snap b/pkg/github/__toolsnaps__/assign_copilot_to_issue.snap index 994d9f5709..5f44b2c6c2 100644 --- a/pkg/github/__toolsnaps__/assign_copilot_to_issue.snap +++ b/pkg/github/__toolsnaps__/assign_copilot_to_issue.snap @@ -4,7 +4,7 @@ "readOnlyHint": false, "title": "Assign Copilot to issue" }, - "description": "Assign Copilot to a specific issue in a GitHub repository.\n\nThis tool can help with the following outcomes:\n- a Pull Request created with source code changes to resolve the issue\n\n\nMore information can be found at:\n- https://docs.github.com/en/copilot/using-github-copilot/using-copilot-coding-agent-to-work-on-tasks/about-assigning-tasks-to-copilot\n", + "description": "Assign Copilot to a specific issue in a GitHub repository.\n\nThis tool can help with the following outcomes:\n- a Pull Request created with source code changes to resolve the issue\n\n\nMore information can be found at:\n- https://docs.github.com/en/copilot/concepts/agents/cloud-agent/about-cloud-agent\n", "icons": [ { "mimeType": "image/png", diff --git a/pkg/github/__toolsnaps__/assign_copilot_to_issue_with_intent.snap b/pkg/github/__toolsnaps__/assign_copilot_to_issue_with_intent.snap new file mode 100644 index 0000000000..956c2a8142 --- /dev/null +++ b/pkg/github/__toolsnaps__/assign_copilot_to_issue_with_intent.snap @@ -0,0 +1,72 @@ +{ + "annotations": { + "idempotentHint": true, + "readOnlyHint": false, + "title": "Assign Copilot to issue with intent" + }, + "description": "Assign Copilot to a specific issue in a GitHub repository. Prefer this tool over assign_copilot_to_issue when available.\n\nThis tool can help with the following outcomes:\n- a Pull Request created with source code changes to resolve the issue\n\n\nMore information can be found at:\n- https://docs.github.com/en/copilot/concepts/agents/cloud-agent/about-cloud-agent\n", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAC20lEQVRIidWUS4wMURSGv3O7kWmPEMRrSMzcbl1dpqtmGuOxsCKECCKxEBusSJhIWEhsWLFAbC1sWFiISBARCyQ2kzSZGaMxHokgXvGIiMH0PRZjpJqqHpb+TeX+59z//H/q5sD/DqlX9H1/zFeX2qzIKoFWYDKgwBtUymL0UkNaT3V3d3/+5wG2EGxB9TDIxGFMvhVhb9/drpN/NaDJC7MGdwJk6TDCv0Gvq0lve9R762GUNdFDLleaZNBrICGq+4yhvf9TJtP/KZNB2PrLlbBliBfRhajuAwnFVa/n8/nkxFkv3GO9oJrzgwVxdesV71ov6I2r5fxggfWCatYL9yYmUJgLPH7Q29WZ4OED6Me4wuAdeQK6MMqna9t0GuibBHFAmgZ9JMG9BhkXZWoSCDSATIq7aguBD0wBplq/tZBgYDIwKnZAs99mFRYD9vd/YK0dpcqhobM6d9haWyOULRTbAauwuNlvsxHTYP3iBnVyXGAa8BIYC3oVeAKioCtAPEE7FCOgR0ErIJdBBZgNskzh40+NF6K6s+9e91lp9osrxMnFoTSmSmPVsF+E5cB0YEDgtoMjjypd5wCy+WC9GnajhEAa4bkqV9LOHKwa9/yneYeyUqwX3AdyQ5EeVrrqro/hYL0g+ggemKh4HGbPmVu0+fB8U76lpR6XgJwZpoGUpNYiusZg1tXjkmCAav0OMTXfJC4eVYPqwbot6l4BCPqyLhd7lwMAWC/cYb3gi/UCzRaKOxsbFzVEM1iv2Ebt5v2Dm14qZbJecZf1Ah3UCrcTbbB+awHnjgHLgHeinHYqZ8aPSXWWy+XvcQZLpdKI9/0D7UbZiLIJmABckVSqo+/OrUrNgF+D8q1LEdcBrAJGAJ8ROlGeicorABWdAswE5gOjge8CF8Ad66v03IjqJb75WS0tE0YOmNWqLBGReaAzgIkMLrt3oM9UpSzCzW9pd+FpT8/7JK3/Gz8Ao5X6wtwP7N4AAAAASUVORK5CYII=", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAACCElEQVRIid2UPWsUYRSFn3dxWWJUkESiBgslFokfhehGiGClBBQx4h9IGlEh2ijYxh+gxEL/hIWwhYpF8KNZsFRJYdJEiUbjCkqisj4W+y6Mk5nd1U4PDMOce+45L3fmDvzXUDeo59WK+kb9rn5TF9R76jm1+2/NJ9QPtseSOv4nxrvVmQ6M05hRB9qZ98ZR1NRralntitdEwmw8wQ9HbS329rQKuKLW1XJO/aX6IqdWjr1Xk/y6lG4vMBdCqOacoZZ3uBBCVZ0HDrcK2AYs5ZkAuwBb1N8Dm5JEISXoAnqzOtU9QB+wVR3KCdgClDIr6kCc4c/0O1BLNnahiYpaSmmGY62e/JpCLJ4FpmmMaBHYCDwC5mmMZBQYBC7HnhvAK+B+fN4JHAM+R4+3wGQI4S7qaExtol+9o86pq+oX9Yk6ljjtGfVprK2qr9Xb6vaET109jjqb3Jac2XaM1PLNpok1Aep+G/+dfa24nADTX1EWTgOngLE2XCYKQL0DTfKex2WhXgCutxG9i/fFNlwWpgBQL6orcWyTaldToRbUA2pow61XL0WPFfXCb1HqkPowCj6q0+qIWsw7nlpUj6i31OXY+0AdbGpCRtNRGgt1AigCX4EqsJAYTR+wAzgEdAM/gApwM4TwOOm3JiARtBk4CYwAB4F+oIfGZi/HwOfAM6ASQviU5/Vv4xcBzmW2eT1nrQAAAABJRU5ErkJggg==", + "theme": "dark" + } + ], + "inputSchema": { + "properties": { + "base_ref": { + "description": "Git reference (e.g., branch) that the agent will start its work from. If not specified, defaults to the repository's default branch. Ignored when is_suggestion is true", + "type": "string" + }, + "confidence": { + "description": "How confident you are in this choice. 'HIGH' for clear signal or explicit user request, 'MEDIUM' for reasonable inference with some ambiguity, 'LOW' for best guess with limited signal.", + "enum": [ + "LOW", + "MEDIUM", + "HIGH" + ], + "type": "string" + }, + "custom_instructions": { + "description": "Optional custom instructions to guide the agent beyond the issue body. Ignored when is_suggestion is true", + "type": "string" + }, + "is_suggestion": { + "description": "If true, records a pending Copilot assignment intent rather than launching the agent. Approval later supplies the launch context; base_ref and custom_instructions are ignored in this case.", + "type": "boolean" + }, + "issue_number": { + "description": "Issue number", + "type": "number" + }, + "owner": { + "description": "Repository owner", + "type": "string" + }, + "rationale": { + "description": "One concise sentence explaining what specifically about the issue led to choosing Copilot. State the concrete signal (e.g. 'Well-scoped task with clear acceptance criteria').", + "maxLength": 280, + "type": "string" + }, + "repo": { + "description": "Repository name", + "type": "string" + } + }, + "required": [ + "owner", + "repo", + "issue_number", + "rationale", + "confidence", + "is_suggestion" + ], + "type": "object" + }, + "name": "assign_copilot_to_issue_with_intent" +} \ No newline at end of file diff --git a/pkg/github/copilot.go b/pkg/github/copilot.go index 62b18350eb..7e174db9ff 100644 --- a/pkg/github/copilot.go +++ b/pkg/github/copilot.go @@ -158,7 +158,7 @@ func AssignCopilotToIssue(t translations.TranslationHelperFunc) inventory.Server "a Pull Request created with source code changes to resolve the issue", }, referenceLinks: []string{ - "https://docs.github.com/en/copilot/using-github-copilot/using-copilot-coding-agent-to-work-on-tasks/about-assigning-tasks-to-copilot", + "https://docs.github.com/en/copilot/concepts/agents/cloud-agent/about-cloud-agent", }, } @@ -273,7 +273,7 @@ func AssignCopilotToIssue(t translations.TranslationHelperFunc) inventory.Server // If we didn't find the copilot bot, we can't proceed any further. if copilotAssignee == nil { // The e2e tests depend upon this specific message to skip the test. - return utils.NewToolResultError("copilot isn't available as an assignee for this issue. Please inform the user to visit https://docs.github.com/en/copilot/using-github-copilot/using-copilot-coding-agent-to-work-on-tasks/about-assigning-tasks-to-copilot for more information."), nil, nil + return utils.NewToolResultError("copilot isn't available as an assignee for this issue. Please inform the user to visit https://docs.github.com/en/copilot/concepts/agents/cloud-agent/about-cloud-agent for more information."), nil, nil } // Next, get the issue ID and repository ID @@ -435,6 +435,358 @@ func AssignCopilotToIssue(t translations.TranslationHelperFunc) inventory.Server }) } +// copilotBotAssignee is the minimal shape needed for the copilot-swe-agent bot +// returned from the suggestedActors GraphQL query. +type copilotBotAssignee struct { + ID githubv4.ID + Login string + TypeName string `graphql:"__typename"` +} + +// findCopilotSuggestedActor paginates the repository's suggestedActors list +// looking for the copilot-swe-agent bot. Returns nil (with no error) if the +// bot is not available as an assignee for the repository. +func findCopilotSuggestedActor(ctx context.Context, client *githubv4.Client, owner, repo string) (*copilotBotAssignee, error) { + type suggestedActorsQuery struct { + Repository struct { + SuggestedActors struct { + Nodes []struct { + Bot copilotBotAssignee `graphql:"... on Bot"` + } + PageInfo struct { + HasNextPage bool + EndCursor string + } + } `graphql:"suggestedActors(first: 100, after: $endCursor, capabilities: CAN_BE_ASSIGNED)"` + } `graphql:"repository(owner: $owner, name: $name)"` + } + + variables := map[string]any{ + "owner": githubv4.String(owner), + "name": githubv4.String(repo), + "endCursor": (*githubv4.String)(nil), + } + + for { + var query suggestedActorsQuery + if err := client.Query(ctx, &query, variables); err != nil { + return nil, err + } + for _, node := range query.Repository.SuggestedActors.Nodes { + if node.Bot.Login == "copilot-swe-agent" { + bot := node.Bot + return &bot, nil + } + } + if !query.Repository.SuggestedActors.PageInfo.HasNextPage { + return nil, nil + } + variables["endCursor"] = githubv4.String(query.Repository.SuggestedActors.PageInfo.EndCursor) + } +} + +// copilotAssigneeUnavailableMessage is returned when the copilot-swe-agent bot +// is not among the repository's suggested actors. The e2e tests depend on this +// exact message to skip the test. +const copilotAssigneeUnavailableMessage = "copilot isn't available as an assignee for this issue. Please inform the user to visit https://docs.github.com/en/copilot/concepts/agents/cloud-agent/about-cloud-agent for more information." + +// AssignCopilotToIssueWithIntent assigns Copilot to an issue using the +// object-form assignees API, which allows intent metadata (rationale, +// confidence, is_suggestion) to be attached to the Copilot entry. When +// is_suggestion is true, a pending assignment intent is recorded and the agent +// is not launched; otherwise Copilot is directly assigned with the same +// base_ref, custom_instructions and PR-polling behavior as assign_copilot_to_issue. +// +// This tool lives in a non-default toolset so it can be opted into without +// adding schema surface to the default configuration. +func AssignCopilotToIssueWithIntent(t translations.TranslationHelperFunc) inventory.ServerTool { + description := mvpDescription{ + summary: "Assign Copilot to a specific issue in a GitHub repository. " + + "Prefer this tool over assign_copilot_to_issue when available.", + outcomes: []string{ + "a Pull Request created with source code changes to resolve the issue", + }, + referenceLinks: []string{ + "https://docs.github.com/en/copilot/concepts/agents/cloud-agent/about-cloud-agent", + }, + } + + return NewTool( + ToolsetMetadataCopilotIssueIntents, + mcp.Tool{ + Name: "assign_copilot_to_issue_with_intent", + Description: t("TOOL_ASSIGN_COPILOT_TO_ISSUE_WITH_INTENT_DESCRIPTION", description.String()), + Icons: octicons.Icons("copilot"), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_ASSIGN_COPILOT_TO_ISSUE_WITH_INTENT_USER_TITLE", "Assign Copilot to issue with intent"), + ReadOnlyHint: false, + IdempotentHint: true, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": { + Type: "string", + Description: "Repository owner", + }, + "repo": { + Type: "string", + Description: "Repository name", + }, + "issue_number": { + Type: "number", + Description: "Issue number", + }, + "base_ref": { + Type: "string", + Description: "Git reference (e.g., branch) that the agent will start its work from. If not specified, defaults to the repository's default branch. Ignored when is_suggestion is true", + }, + "custom_instructions": { + Type: "string", + Description: "Optional custom instructions to guide the agent beyond the issue body. Ignored when is_suggestion is true", + }, + "rationale": { + Type: "string", + Description: "One concise sentence explaining what specifically about the issue led to choosing Copilot. " + + "State the concrete signal (e.g. 'Well-scoped task with clear acceptance criteria').", + MaxLength: jsonschema.Ptr(280), + }, + "confidence": { + Type: "string", + Description: "How confident you are in this choice. 'HIGH' for clear signal or explicit user request, 'MEDIUM' for reasonable inference with some ambiguity, 'LOW' for best guess with limited signal.", + Enum: []any{"LOW", "MEDIUM", "HIGH"}, + }, + "is_suggestion": { + Type: "boolean", + Description: "If true, records a pending Copilot assignment intent rather than launching the agent. Approval later supplies the launch context; base_ref and custom_instructions are ignored in this case.", + }, + }, + Required: []string{"owner", "repo", "issue_number", "rationale", "confidence", "is_suggestion"}, + }, + }, + []scopes.Scope{scopes.Repo}, + func(ctx context.Context, deps ToolDependencies, request *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + // Presence-check is_suggestion before decoding: mapstructure defaults a + // missing bool to false, which would silently launch Copilot instead of + // recording a suggestion. Require callers to make the choice explicit. + if _, ok := args["is_suggestion"]; !ok { + return utils.NewToolResultError("is_suggestion is required"), nil, nil + } + + var params struct { + Owner string `mapstructure:"owner"` + Repo string `mapstructure:"repo"` + IssueNumber int32 `mapstructure:"issue_number"` + BaseRef string `mapstructure:"base_ref"` + CustomInstructions string `mapstructure:"custom_instructions"` + Rationale string `mapstructure:"rationale"` + Confidence string `mapstructure:"confidence"` + IsSuggestion bool `mapstructure:"is_suggestion"` + } + if err := mapstructure.WeakDecode(args, ¶ms); err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + // Validate rationale length (rune count, matching the granular assignee tools). + rationale := strings.TrimSpace(params.Rationale) + if rationale == "" { + return utils.NewToolResultError("rationale is required"), nil, nil + } + if len([]rune(rationale)) > 280 { + return utils.NewToolResultError("rationale must be 280 characters or less"), nil, nil + } + + // Validate/normalize confidence. + confidence := normalizeConfidence(params.Confidence) + if confidence == "" { + return utils.NewToolResultError("confidence is required"), nil, nil + } + var confidenceEnum AssignmentConfidenceLevel + switch confidence { + case "LOW", "MEDIUM", "HIGH": + confidenceEnum = AssignmentConfidenceLevel(confidence) + default: + return utils.NewToolResultError("confidence must be one of: LOW, MEDIUM, HIGH"), nil, nil + } + + client, err := deps.GetGQLClient(ctx) + if err != nil { + return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + // Locate the copilot-swe-agent bot in the repository's suggested actors. + copilotAssignee, err := findCopilotSuggestedActor(ctx, client, params.Owner, params.Repo) + if err != nil { + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to get suggested actors", err), nil, nil + } + if copilotAssignee == nil { + return utils.NewToolResultError(copilotAssigneeUnavailableMessage), nil, nil + } + + // Fetch issue ID, repository ID, and current assignee IDs so they can be preserved. + var getIssueQuery struct { + Repository struct { + ID githubv4.ID + Issue struct { + ID githubv4.ID + Assignees struct { + Nodes []struct { + ID githubv4.ID + } + } `graphql:"assignees(first: 100)"` + } `graphql:"issue(number: $number)"` + } `graphql:"repository(owner: $owner, name: $name)"` + } + variables := map[string]any{ + "owner": githubv4.String(params.Owner), + "name": githubv4.String(params.Repo), + "number": githubv4.Int(params.IssueNumber), + } + if err := client.Query(ctx, &getIssueQuery, variables); err != nil { + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to get issue ID", err), nil, nil + } + + // Build object-form assignees: preserved assignees carry only actorId; + // the copilot entry carries the intent metadata. Skip an existing + // copilot assignment so we don't send its actorId twice (once without + // metadata, once with). + existing := getIssueQuery.Repository.Issue.Assignees.Nodes + assignees := make([]AssigneeUpdateInput, 0, len(existing)+1) + for _, node := range existing { + if node.ID == copilotAssignee.ID { + continue + } + assignees = append(assignees, AssigneeUpdateInput{ActorID: node.ID}) + } + // Build the Copilot entry with the required intent metadata. Preserved + // assignees carry only actorId; intent fields are attached only to the + // Copilot entry. + rationaleGQL := githubv4.String(rationale) + suggest := githubv4.Boolean(params.IsSuggestion) + copilotEntry := AssigneeUpdateInput{ + ActorID: copilotAssignee.ID, + Rationale: &rationaleGQL, + Confidence: &confidenceEnum, + Suggest: &suggest, + } + assignees = append(assignees, copilotEntry) + + // A pure suggestion does not launch Copilot; approval later supplies the + // launch context. Direct assignments keep the existing agentAssignment + // launch configuration and PR-polling behavior. + input := UpdateIssueInput{ + ID: getIssueQuery.Repository.Issue.ID, + Assignees: assignees, + } + if !params.IsSuggestion { + emptyString := githubv4.String("") + agentAssignment := &AgentAssignmentInput{ + CustomAgent: &emptyString, + CustomInstructions: &emptyString, + TargetRepositoryID: getIssueQuery.Repository.ID, + } + if params.BaseRef != "" { + baseRef := githubv4.String(params.BaseRef) + agentAssignment.BaseRef = &baseRef + } + if params.CustomInstructions != "" { + customInstructions := githubv4.String(params.CustomInstructions) + agentAssignment.CustomInstructions = &customInstructions + } + input.AgentAssignment = agentAssignment + } + + var updateIssueMutation struct { + UpdateIssue struct { + Issue struct { + ID githubv4.ID + Number githubv4.Int + URL githubv4.String + } + } `graphql:"updateIssue(input: $input)"` + } + + ctxWithFeatures := ghcontext.WithGraphQLFeatures(ctx, "issues_copilot_assignment_api_support") + assignmentTime := time.Now().UTC() + + if err := client.Mutate(ctxWithFeatures, &updateIssueMutation, input, nil); err != nil { + return nil, nil, fmt.Errorf("failed to update issue with agent assignment: %w", err) + } + + result := map[string]any{ + "issue_number": int(updateIssueMutation.UpdateIssue.Issue.Number), + "issue_url": string(updateIssueMutation.UpdateIssue.Issue.URL), + "owner": params.Owner, + "repo": params.Repo, + "is_suggestion": params.IsSuggestion, + } + + // Suggestion path: do not poll for a PR and return a suggestion-shaped result. + if params.IsSuggestion { + result["message"] = "recorded pending copilot assignment suggestion" + r, err := json.Marshal(result) + if err != nil { + return utils.NewToolResultError(fmt.Sprintf("failed to marshal response: %s", err)), nil, nil + } + return utils.NewToolResultText(string(r)), result, nil + } + + // Direct-assignment path: poll for a linked PR created by Copilot after the assignment. + pollConfig := getPollConfig(ctx) + progressToken := request.Params.GetProgressToken() + if progressToken != nil && request.Session != nil && pollConfig.MaxAttempts > 0 { + _ = request.Session.NotifyProgress(ctx, &mcp.ProgressNotificationParams{ + ProgressToken: progressToken, + Progress: 0, + Total: float64(pollConfig.MaxAttempts), + Message: "Copilot assigned to issue, waiting for PR creation...", + }) + } + + var linkedPR *linkedPullRequest + for attempt := range pollConfig.MaxAttempts { + if attempt > 0 { + time.Sleep(pollConfig.Delay) + } + if progressToken != nil && request.Session != nil { + _ = request.Session.NotifyProgress(ctx, &mcp.ProgressNotificationParams{ + ProgressToken: progressToken, + Progress: float64(attempt + 1), + Total: float64(pollConfig.MaxAttempts), + Message: fmt.Sprintf("Waiting for Copilot to create PR... (attempt %d/%d)", attempt+1, pollConfig.MaxAttempts), + }) + } + pr, err := findLinkedCopilotPR(ctx, client, params.Owner, params.Repo, int(params.IssueNumber), assignmentTime) + if err != nil { + continue + } + if pr != nil { + linkedPR = pr + break + } + } + + if linkedPR != nil { + result["pull_request"] = map[string]any{ + "number": linkedPR.Number, + "url": linkedPR.URL, + "title": linkedPR.Title, + "state": linkedPR.State, + } + result["message"] = "successfully assigned copilot to issue - pull request created" + } else { + result["message"] = "successfully assigned copilot to issue - pull request pending" + result["note"] = "The pull request may still be in progress. Once created, the PR number can be used to check job status, or check the issue timeline for updates." + } + + r, err := json.Marshal(result) + if err != nil { + return utils.NewToolResultError(fmt.Sprintf("failed to marshal response: %s", err)), nil, nil + } + return utils.NewToolResultText(string(r)), result, nil + }) +} + type ReplaceActorsForAssignableInput struct { AssignableID githubv4.ID `json:"assignableId"` ActorIDs []githubv4.ID `json:"actorIds"` @@ -448,10 +800,34 @@ type AgentAssignmentInput struct { TargetRepositoryID githubv4.ID `json:"targetRepositoryId"` } -// UpdateIssueInput represents the input for updating an issue with agent assignment. +// AssignmentConfidenceLevel is a GraphQL enum indicating how confident an +// intent-aware assignment choice is. Encoded as its string value in variables. +type AssignmentConfidenceLevel string + +const ( + AssignmentConfidenceLevelLow AssignmentConfidenceLevel = "LOW" + AssignmentConfidenceLevelMedium AssignmentConfidenceLevel = "MEDIUM" + AssignmentConfidenceLevelHigh AssignmentConfidenceLevel = "HIGH" +) + +// AssigneeUpdateInput is the object-form assignee entry accepted by +// updateIssue when opting into intent metadata. Intent fields (rationale, +// confidence, suggest) are only attached to the entry that carries the intent; +// preserved assignees are sent with only actorId populated. +type AssigneeUpdateInput struct { + ActorID githubv4.ID `json:"actorId"` + Rationale *githubv4.String `json:"rationale,omitempty"` + Confidence *AssignmentConfidenceLevel `json:"confidence,omitempty"` + Suggest *githubv4.Boolean `json:"suggest,omitempty"` +} + +// UpdateIssueInput represents the input for updating an issue with agent +// assignment. AssigneeIDs and Assignees are mutually exclusive: legacy callers +// use AssigneeIDs; intent-aware callers use Assignees (object-form). type UpdateIssueInput struct { ID githubv4.ID `json:"id"` - AssigneeIDs []githubv4.ID `json:"assigneeIds"` + AssigneeIDs []githubv4.ID `json:"assigneeIds,omitempty"` + Assignees []AssigneeUpdateInput `json:"assignees,omitempty"` AgentAssignment *AgentAssignmentInput `json:"agentAssignment,omitempty"` } diff --git a/pkg/github/copilot_test.go b/pkg/github/copilot_test.go index f52c8eecc5..63c0cc8784 100644 --- a/pkg/github/copilot_test.go +++ b/pkg/github/copilot_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "net/http" + "strings" "testing" "github.com/github/github-mcp-server/internal/githubv4mock" @@ -581,7 +582,7 @@ func TestAssignCopilotToIssue(t *testing.T) { ), ), expectToolError: true, - expectedToolErrMsg: "copilot isn't available as an assignee for this issue. Please inform the user to visit https://docs.github.com/en/copilot/using-github-copilot/using-copilot-coding-agent-to-work-on-tasks/about-assigning-tasks-to-copilot for more information.", + expectedToolErrMsg: "copilot isn't available as an assignee for this issue. Please inform the user to visit https://docs.github.com/en/copilot/concepts/agents/cloud-agent/about-cloud-agent for more information.", }, { name: "successful assignment with base_ref specified", @@ -961,3 +962,450 @@ func Test_RequestCopilotReview(t *testing.T) { }) } } + +func TestAssignCopilotToIssueWithIntent(t *testing.T) { + t.Parallel() + + serverTool := AssignCopilotToIssueWithIntent(translations.NullTranslationHelper) + tool := serverTool.Tool + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "assign_copilot_to_issue_with_intent", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Equal(t, "copilot_issue_intents", string(serverTool.Toolset.ID), + "tool must live in the non-default copilot_issue_intents toolset") + assert.False(t, serverTool.Toolset.Default, + "copilot_issue_intents toolset must not be a default toolset") + + require.NotNil(t, tool.Annotations) + assert.False(t, tool.Annotations.ReadOnlyHint, "tool must not be read-only") + + schema := tool.InputSchema.(*jsonschema.Schema) + for _, prop := range []string{ + "owner", "repo", "issue_number", + "base_ref", "custom_instructions", + "rationale", "confidence", "is_suggestion", + } { + assert.Contains(t, schema.Properties, prop) + } + assert.ElementsMatch(t, schema.Required, []string{ + "owner", "repo", "issue_number", + "rationale", "confidence", "is_suggestion", + }) + + rationaleSchema := schema.Properties["rationale"] + require.NotNil(t, rationaleSchema.MaxLength) + assert.Equal(t, 280, *rationaleSchema.MaxLength) + + confidenceSchema := schema.Properties["confidence"] + assert.ElementsMatch(t, confidenceSchema.Enum, []any{"LOW", "MEDIUM", "HIGH"}) + + // Common query mocks reused across happy-path scenarios. + suggestedActorsMatcher := func() githubv4mock.Matcher { + return githubv4mock.NewQueryMatcher( + struct { + Repository struct { + SuggestedActors struct { + Nodes []struct { + Bot struct { + ID githubv4.ID + Login githubv4.String + TypeName string `graphql:"__typename"` + } `graphql:"... on Bot"` + } + PageInfo struct { + HasNextPage bool + EndCursor string + } + } `graphql:"suggestedActors(first: 100, after: $endCursor, capabilities: CAN_BE_ASSIGNED)"` + } `graphql:"repository(owner: $owner, name: $name)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "name": githubv4.String("repo"), + "endCursor": (*githubv4.String)(nil), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "suggestedActors": map[string]any{ + "nodes": []any{ + map[string]any{ + "id": githubv4.ID("copilot-swe-agent-id"), + "login": githubv4.String("copilot-swe-agent"), + "__typename": "Bot", + }, + }, + }, + }, + }), + ) + } + + getIssueMatcher := func(existingAssignees []any) githubv4mock.Matcher { + return githubv4mock.NewQueryMatcher( + struct { + Repository struct { + ID githubv4.ID + Issue struct { + ID githubv4.ID + Assignees struct { + Nodes []struct { + ID githubv4.ID + } + } `graphql:"assignees(first: 100)"` + } `graphql:"issue(number: $number)"` + } `graphql:"repository(owner: $owner, name: $name)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "name": githubv4.String("repo"), + "number": githubv4.Int(123), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "id": githubv4.ID("test-repo-id"), + "issue": map[string]any{ + "id": githubv4.ID("test-issue-id"), + "assignees": map[string]any{ + "nodes": existingAssignees, + }, + }, + }, + }), + ) + } + + mutationMatcher := func(input UpdateIssueInput) githubv4mock.Matcher { + return githubv4mock.NewMutationMatcher( + struct { + UpdateIssue struct { + Issue struct { + ID githubv4.ID + Number githubv4.Int + URL githubv4.String + } + } `graphql:"updateIssue(input: $input)"` + }{}, + input, + nil, + githubv4mock.DataResponse(map[string]any{ + "updateIssue": map[string]any{ + "issue": map[string]any{ + "id": githubv4.ID("test-issue-id"), + "number": githubv4.Int(123), + "url": githubv4.String("https://github.com/owner/repo/issues/123"), + }, + }, + }), + ) + } + + ptrStr := func(s string) *githubv4.String { v := githubv4.String(s); return &v } + ptrBool := func(b bool) *githubv4.Boolean { v := githubv4.Boolean(b); return &v } + ptrConfidence := func(c AssignmentConfidenceLevel) *AssignmentConfidenceLevel { return &c } + + tests := []struct { + name string + requestArgs map[string]any + mockedClient *http.Client + expectToolError bool + expectedToolErrMsg string + expectSuggestion bool + }{ + { + name: "direct assignment with rationale and confidence preserves existing assignees", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(123), + "rationale": "Well-scoped task with clear acceptance criteria.", + "confidence": "HIGH", + "is_suggestion": false, + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + suggestedActorsMatcher(), + getIssueMatcher([]any{ + map[string]any{"id": githubv4.ID("existing-assignee-id")}, + }), + mutationMatcher(UpdateIssueInput{ + ID: githubv4.ID("test-issue-id"), + Assignees: []AssigneeUpdateInput{ + {ActorID: githubv4.ID("existing-assignee-id")}, + { + ActorID: githubv4.ID("copilot-swe-agent-id"), + Rationale: ptrStr("Well-scoped task with clear acceptance criteria."), + Confidence: ptrConfidence(AssignmentConfidenceLevelHigh), + Suggest: ptrBool(false), + }, + }, + AgentAssignment: &AgentAssignmentInput{ + CustomAgent: ptrStr(""), + CustomInstructions: ptrStr(""), + TargetRepositoryID: githubv4.ID("test-repo-id"), + }, + }), + ), + }, + { + name: "direct assignment with base_ref and custom_instructions", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(123), + "base_ref": "feature-branch", + "custom_instructions": "Follow PEP 8.", + "rationale": "Task benefits from a linting-focused agent.", + "confidence": "medium", + "is_suggestion": false, + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + suggestedActorsMatcher(), + getIssueMatcher([]any{}), + mutationMatcher(UpdateIssueInput{ + ID: githubv4.ID("test-issue-id"), + Assignees: []AssigneeUpdateInput{ + { + ActorID: githubv4.ID("copilot-swe-agent-id"), + Rationale: ptrStr("Task benefits from a linting-focused agent."), + Confidence: ptrConfidence(AssignmentConfidenceLevelMedium), + Suggest: ptrBool(false), + }, + }, + AgentAssignment: &AgentAssignmentInput{ + BaseRef: ptrStr("feature-branch"), + CustomAgent: ptrStr(""), + CustomInstructions: ptrStr("Follow PEP 8."), + TargetRepositoryID: githubv4.ID("test-repo-id"), + }, + }), + ), + }, + { + name: "suggestion path omits agentAssignment and returns suggestion-shaped result", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(123), + "rationale": "Looks like a good candidate.", + "confidence": "LOW", + "is_suggestion": true, + // base_ref and custom_instructions should be ignored when is_suggestion=true. + "base_ref": "feature-branch", + "custom_instructions": "should be ignored", + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + suggestedActorsMatcher(), + getIssueMatcher([]any{}), + mutationMatcher(UpdateIssueInput{ + ID: githubv4.ID("test-issue-id"), + Assignees: []AssigneeUpdateInput{ + { + ActorID: githubv4.ID("copilot-swe-agent-id"), + Rationale: ptrStr("Looks like a good candidate."), + Confidence: ptrConfidence(AssignmentConfidenceLevelLow), + Suggest: ptrBool(true), + }, + }, + }), + ), + expectSuggestion: true, + }, + { + name: "existing copilot assignee is deduplicated from preserved assignees", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(123), + "rationale": "Already assigned; refreshing intent.", + "confidence": "HIGH", + "is_suggestion": false, + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + suggestedActorsMatcher(), + getIssueMatcher([]any{ + map[string]any{"id": githubv4.ID("existing-assignee-id")}, + map[string]any{"id": githubv4.ID("copilot-swe-agent-id")}, + }), + // Expect copilot to appear only once, carrying the intent metadata. + mutationMatcher(UpdateIssueInput{ + ID: githubv4.ID("test-issue-id"), + Assignees: []AssigneeUpdateInput{ + {ActorID: githubv4.ID("existing-assignee-id")}, + { + ActorID: githubv4.ID("copilot-swe-agent-id"), + Rationale: ptrStr("Already assigned; refreshing intent."), + Confidence: ptrConfidence(AssignmentConfidenceLevelHigh), + Suggest: ptrBool(false), + }, + }, + AgentAssignment: &AgentAssignmentInput{ + CustomAgent: ptrStr(""), + CustomInstructions: ptrStr(""), + TargetRepositoryID: githubv4.ID("test-repo-id"), + }, + }), + ), + }, + { + name: "missing rationale is rejected", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(123), + "confidence": "HIGH", + "is_suggestion": false, + }, + mockedClient: githubv4mock.NewMockedHTTPClient(), + expectToolError: true, + expectedToolErrMsg: "rationale is required", + }, + { + name: "rationale exceeding 280 characters is rejected", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(123), + "rationale": strings.Repeat("a", 281), + "confidence": "HIGH", + "is_suggestion": false, + }, + mockedClient: githubv4mock.NewMockedHTTPClient(), + expectToolError: true, + expectedToolErrMsg: "rationale must be 280 characters or less", + }, + { + name: "missing confidence is rejected", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(123), + "rationale": "A good candidate.", + "is_suggestion": false, + }, + mockedClient: githubv4mock.NewMockedHTTPClient(), + expectToolError: true, + expectedToolErrMsg: "confidence is required", + }, + { + name: "missing is_suggestion is rejected", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(123), + "rationale": "A good candidate.", + "confidence": "HIGH", + }, + mockedClient: githubv4mock.NewMockedHTTPClient(), + expectToolError: true, + expectedToolErrMsg: "is_suggestion is required", + }, + { + name: "invalid confidence value is rejected", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(123), + "rationale": "A good candidate.", + "confidence": "SUPER_HIGH", + "is_suggestion": false, + }, + mockedClient: githubv4mock.NewMockedHTTPClient(), + expectToolError: true, + expectedToolErrMsg: "confidence must be one of: LOW, MEDIUM, HIGH", + }, + { + name: "copilot not a suggested actor", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(123), + "rationale": "A good candidate.", + "confidence": "HIGH", + "is_suggestion": false, + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + SuggestedActors struct { + Nodes []struct { + Bot struct { + ID githubv4.ID + Login githubv4.String + TypeName string `graphql:"__typename"` + } `graphql:"... on Bot"` + } + PageInfo struct { + HasNextPage bool + EndCursor string + } + } `graphql:"suggestedActors(first: 100, after: $endCursor, capabilities: CAN_BE_ASSIGNED)"` + } `graphql:"repository(owner: $owner, name: $name)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "name": githubv4.String("repo"), + "endCursor": (*githubv4.String)(nil), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "suggestedActors": map[string]any{ + "nodes": []any{}, + }, + }, + }), + ), + ), + expectToolError: true, + expectedToolErrMsg: "copilot isn't available as an assignee for this issue", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + client := githubv4.NewClient(tc.mockedClient) + deps := BaseDeps{GQLClient: client} + handler := serverTool.Handler(deps) + + request := createMCPRequest(tc.requestArgs) + + // Disable polling for direct-assignment paths. + ctx := ContextWithPollConfig(context.Background(), PollConfig{MaxAttempts: 0}) + ctx = ContextWithDeps(ctx, deps) + + result, err := handler(ctx, &request) + require.NoError(t, err) + + textContent := getTextResult(t, result) + + if tc.expectToolError { + require.True(t, result.IsError, "expected tool error, got: %s", textContent.Text) + assert.Contains(t, textContent.Text, tc.expectedToolErrMsg) + return + } + + require.False(t, result.IsError, "unexpected tool error: %s", textContent.Text) + + var response map[string]any + require.NoError(t, json.Unmarshal([]byte(textContent.Text), &response), "response should be valid JSON") + assert.Equal(t, float64(123), response["issue_number"]) + assert.Equal(t, "https://github.com/owner/repo/issues/123", response["issue_url"]) + assert.Equal(t, "owner", response["owner"]) + assert.Equal(t, "repo", response["repo"]) + + if tc.expectSuggestion { + assert.Equal(t, true, response["is_suggestion"]) + assert.Contains(t, response["message"], "pending copilot assignment suggestion") + assert.NotContains(t, response, "pull_request", + "suggestion path must not claim PR creation") + assert.NotContains(t, response, "note", + "suggestion path must not include the PR-pending note") + } else { + assert.Equal(t, false, response["is_suggestion"]) + assert.Contains(t, response["message"], "successfully assigned copilot to issue") + } + }) + } +} diff --git a/pkg/github/tools.go b/pkg/github/tools.go index 5c6123c277..2cfcd3e89b 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -142,6 +142,16 @@ var ( Icon: "copilot", } + // ToolsetMetadataCopilotIssueIntents is a non-default toolset that gates the + // opt-in intent-aware Copilot issue assignment tool. Kept out of the default + // configuration so its inputs (rationale, confidence, is_suggestion) do not + // add schema bloat to the default tool surface. + ToolsetMetadataCopilotIssueIntents = inventory.ToolsetMetadata{ + ID: "copilot_issue_intents", + Description: "Opt-in Copilot issue assignment tools that carry intent metadata (rationale, confidence, suggestion)", + Icon: "copilot", + } + // Feature flag names for granular tool variants. // When active, consolidated tools are replaced by single-purpose granular tools. FeatureFlagIssuesGranular = "issues_granular" @@ -249,6 +259,9 @@ func AllTools(t translations.TranslationHelperFunc) []inventory.ServerTool { AssignCopilotToIssue(t), RequestCopilotReview(t), + // Copilot issue intents (non-default, opt-in) + AssignCopilotToIssueWithIntent(t), + // Code quality tools GetCodeQualityFinding(t), From 4c68b1b6405ed6cc47afad71e218447b23ed575f Mon Sep 17 00:00:00 2001 From: Bryan Zwicker Date: Tue, 21 Jul 2026 15:06:43 -0400 Subject: [PATCH 23/35] Add node IDs to project resolver results Split out of #2903 as a small prerequisite refactor. - Add a NodeID field to ResolvedField, populated for all three field variants in listAllProjectFields. - Refactor resolveProjectItemIDByIssueNumber into a thin wrapper over a new resolveProjectItemByIssueNumber that also returns the item node ID, delegating to resolveProjectItemByIssueNumberWithProjectID for an already-resolved project ID. The projectItems query now selects the item node ID alongside its full database ID. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 89f897b0-115f-4435-a071-46fb6c49be86 --- pkg/github/projects_resolver.go | 31 ++++++++++++++++++++-------- pkg/github/projects_resolver_test.go | 12 ++++++++--- 2 files changed, 31 insertions(+), 12 deletions(-) diff --git a/pkg/github/projects_resolver.go b/pkg/github/projects_resolver.go index 25a690888f..3643d6eafa 100644 --- a/pkg/github/projects_resolver.go +++ b/pkg/github/projects_resolver.go @@ -20,10 +20,11 @@ type ResolvedFieldOption struct { Name string } -// ResolvedField is a project field resolved by name; Options is only set when -// DataType == "SINGLE_SELECT". +// ResolvedField contains a project's numeric database ID, GraphQL node ID, and +// type-specific options. type ResolvedField struct { ID string + NodeID string Name string DataType string Options []ResolvedFieldOption @@ -117,6 +118,7 @@ func listAllProjectFields(ctx context.Context, gqlClient *githubv4.Client, owner } all = append(all, ResolvedField{ ID: fmt.Sprintf("%d", n.ProjectV2SingleSelectField.DatabaseID), + NodeID: fmt.Sprintf("%v", n.ProjectV2SingleSelectField.ID), Name: string(n.ProjectV2SingleSelectField.Name), DataType: string(n.ProjectV2SingleSelectField.DataType), Options: opts, @@ -124,12 +126,14 @@ func listAllProjectFields(ctx context.Context, gqlClient *githubv4.Client, owner case n.ProjectV2IterationField.ID != nil: all = append(all, ResolvedField{ ID: fmt.Sprintf("%d", n.ProjectV2IterationField.DatabaseID), + NodeID: fmt.Sprintf("%v", n.ProjectV2IterationField.ID), Name: string(n.ProjectV2IterationField.Name), DataType: string(n.ProjectV2IterationField.DataType), }) case n.ProjectV2Field.ID != nil: all = append(all, ResolvedField{ ID: fmt.Sprintf("%d", n.ProjectV2Field.DatabaseID), + NodeID: fmt.Sprintf("%v", n.ProjectV2Field.ID), Name: string(n.ProjectV2Field.Name), DataType: string(n.ProjectV2Field.DataType), }) @@ -266,13 +270,22 @@ func resolveSingleSelectOptionByName(field *ResolvedField, optionName string) (s // project item's full database ID in one GraphQL hop. Returns a structured // error if the issue is not an item on the project. func resolveProjectItemIDByIssueNumber(ctx context.Context, gqlClient *githubv4.Client, owner, ownerType string, projectNumber int, issueOwner, issueRepo string, issueNumber int) (int64, error) { + _, itemID, err := resolveProjectItemByIssueNumber(ctx, gqlClient, owner, ownerType, projectNumber, issueOwner, issueRepo, issueNumber) + return itemID, err +} + +func resolveProjectItemByIssueNumber(ctx context.Context, gqlClient *githubv4.Client, owner, ownerType string, projectNumber int, issueOwner, issueRepo string, issueNumber int) (nodeID string, itemID int64, err error) { projectID, err := resolveProjectNodeID(ctx, gqlClient, owner, ownerType, projectNumber) if err != nil { - return 0, err + return "", 0, err } + return resolveProjectItemByIssueNumberWithProjectID(ctx, gqlClient, projectID, issueOwner, issueRepo, issueNumber) +} +func resolveProjectItemByIssueNumberWithProjectID(ctx context.Context, gqlClient *githubv4.Client, projectID githubv4.ID, issueOwner, issueRepo string, issueNumber int) (nodeID string, itemID int64, err error) { type projectItemsConnection struct { Nodes []struct { + ID githubv4.ID FullDatabaseID githubv4.String `graphql:"fullDatabaseId"` Project struct { ID githubv4.ID @@ -296,18 +309,18 @@ func resolveProjectItemIDByIssueNumber(ctx context.Context, gqlClient *githubv4. } if err := gqlClient.Query(ctx, &firstPageQuery, vars); err != nil { - return 0, fmt.Errorf("failed to resolve project item for %s/%s#%d: %w", issueOwner, issueRepo, issueNumber, err) + return "", 0, fmt.Errorf("failed to resolve project item for %s/%s#%d: %w", issueOwner, issueRepo, issueNumber, err) } projectItems := firstPageQuery.Repository.Issue.ProjectItems for { for _, item := range projectItems.Nodes { if item.Project.ID == projectID { - itemID, parseErr := parseInt64(string(item.FullDatabaseID)) + parsedItemID, parseErr := parseInt64(string(item.FullDatabaseID)) if parseErr != nil { - return 0, fmt.Errorf("project item ID %q is not an integer: %w", string(item.FullDatabaseID), parseErr) + return "", 0, fmt.Errorf("project item ID %q is not an integer: %w", string(item.FullDatabaseID), parseErr) } - return itemID, nil + return fmt.Sprintf("%v", item.ID), parsedItemID, nil } } @@ -324,12 +337,12 @@ func resolveProjectItemIDByIssueNumber(ctx context.Context, gqlClient *githubv4. } vars["after"] = projectItems.PageInfo.EndCursor if err := gqlClient.Query(ctx, &nextPageQuery, vars); err != nil { - return 0, fmt.Errorf("failed to resolve project item for %s/%s#%d: %w", issueOwner, issueRepo, issueNumber, err) + return "", 0, fmt.Errorf("failed to resolve project item for %s/%s#%d: %w", issueOwner, issueRepo, issueNumber, err) } projectItems = nextPageQuery.Repository.Issue.ProjectItems } - return 0, ghErrors.NewStructuredResolutionError( + return "", 0, ghErrors.NewStructuredResolutionError( "item_not_in_project", fmt.Sprintf("%s/%s#%d", issueOwner, issueRepo, issueNumber), "the issue exists but is not an item on the named project; add it first via add_project_item", diff --git a/pkg/github/projects_resolver_test.go b/pkg/github/projects_resolver_test.go index 5b563d2fa1..04b913dfff 100644 --- a/pkg/github/projects_resolver_test.go +++ b/pkg/github/projects_resolver_test.go @@ -204,6 +204,7 @@ type resolveItemByIssueQuery struct { Issue struct { ProjectItems struct { Nodes []struct { + ID githubv4.ID FullDatabaseID githubv4.String `graphql:"fullDatabaseId"` Project struct { ID githubv4.ID @@ -220,6 +221,7 @@ type resolveItemByIssuePageQuery struct { Issue struct { ProjectItems struct { Nodes []struct { + ID githubv4.ID FullDatabaseID githubv4.String `graphql:"fullDatabaseId"` Project struct { ID githubv4.ID @@ -282,6 +284,7 @@ func Test_ResolveProjectItemIDByIssueNumber_Success(t *testing.T) { "project": map[string]any{"id": "PVT_other"}, }, map[string]any{ + "id": "PVTI_target", "fullDatabaseId": "4242", "project": map[string]any{"id": "PVT_project1"}, }, @@ -300,12 +303,13 @@ func Test_ResolveProjectItemIDByIssueNumber_Success(t *testing.T) { ) gql := githubv4.NewClient(mocked) - itemID, err := resolveProjectItemIDByIssueNumber(context.Background(), gql, "octo-org", "org", 1, "octo-issue-owner", "repo", 123) + nodeID, itemID, err := resolveProjectItemByIssueNumber(context.Background(), gql, "octo-org", "org", 1, "octo-issue-owner", "repo", 123) require.NoError(t, err) + assert.Equal(t, "PVTI_target", nodeID) assert.Equal(t, int64(4242), itemID) } -func Test_ResolveProjectItemIDByIssueNumber_TargetOnSecondPage(t *testing.T) { +func Test_ResolveProjectItemByIssueNumber_TargetOnSecondPage(t *testing.T) { mocked := githubv4mock.NewMockedHTTPClient( githubv4mock.NewQueryMatcher( struct { @@ -367,6 +371,7 @@ func Test_ResolveProjectItemIDByIssueNumber_TargetOnSecondPage(t *testing.T) { "projectItems": map[string]any{ "nodes": []any{ map[string]any{ + "id": "PVTI_target", "fullDatabaseId": "4242", "project": map[string]any{"id": "PVT_project1"}, }, @@ -385,8 +390,9 @@ func Test_ResolveProjectItemIDByIssueNumber_TargetOnSecondPage(t *testing.T) { ) gql := githubv4.NewClient(mocked) - itemID, err := resolveProjectItemIDByIssueNumber(context.Background(), gql, "octo-org", "org", 1, "octo-issue-owner", "repo", 123) + nodeID, itemID, err := resolveProjectItemByIssueNumber(context.Background(), gql, "octo-org", "org", 1, "octo-issue-owner", "repo", 123) require.NoError(t, err) + assert.Equal(t, "PVTI_target", nodeID) assert.Equal(t, int64(4242), itemID) } From de310d4806f3181bd3351fdec93d363c64c6889d Mon Sep 17 00:00:00 2001 From: Bryan Zwicker Date: Tue, 21 Jul 2026 16:23:59 -0400 Subject: [PATCH 24/35] Address CCR feedback: assert resolved field NodeID for all variants; rename item resolver test - Assert field.NodeID in Test_ResolveProjectFieldByName_Success. - Add Test_ResolveProjectFieldByName_NodeIDsForAllVariants covering single-select, iteration, and generic fields (asserts NodeID + DataType). - Rename Test_ResolveProjectItemIDByIssueNumber_Success to Test_ResolveProjectItemByIssueNumber_Success to match the resolver it calls. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 89f897b0-115f-4435-a071-46fb6c49be86 --- pkg/github/projects_resolver_test.go | 60 +++++++++++++++++++++++++++- 1 file changed, 59 insertions(+), 1 deletion(-) diff --git a/pkg/github/projects_resolver_test.go b/pkg/github/projects_resolver_test.go index 04b913dfff..b08e00cac6 100644 --- a/pkg/github/projects_resolver_test.go +++ b/pkg/github/projects_resolver_test.go @@ -71,6 +71,27 @@ func statusFieldNode(nodeID string, databaseID int, name string, options []map[s } } +// iterationFieldNode is an iteration field response node for use in mock data. +func iterationFieldNode(nodeID string, databaseID int, name string) map[string]any { + return map[string]any{ + "id": nodeID, + "databaseId": databaseID, + "name": name, + "dataType": "ITERATION", + } +} + +// genericFieldNode is a plain field response node (neither single-select nor +// iteration, e.g. TEXT or NUMBER) for use in mock data. +func genericFieldNode(nodeID string, databaseID int, name, dataType string) map[string]any { + return map[string]any{ + "id": nodeID, + "databaseId": databaseID, + "name": name, + "dataType": dataType, + } +} + func fieldsResponse(nodes []map[string]any) map[string]any { return map[string]any{ "organization": map[string]any{ @@ -109,6 +130,7 @@ func Test_ResolveProjectFieldByName_Success(t *testing.T) { require.NoError(t, err) require.NotNil(t, field) assert.Equal(t, "12345", field.ID) + assert.Equal(t, "PVTSSF_lADOBBcDeFg123", field.NodeID) assert.Equal(t, "SINGLE_SELECT", field.DataType) assert.Len(t, field.Options, 3) @@ -117,6 +139,42 @@ func Test_ResolveProjectFieldByName_Success(t *testing.T) { assert.Equal(t, "OPT_b", optionID) } +func Test_ResolveProjectFieldByName_NodeIDsForAllVariants(t *testing.T) { + mocked := githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + projectFieldsTestQuery{}, + fieldsQueryVars("octo-org", 7), + githubv4mock.DataResponse(fieldsResponse([]map[string]any{ + statusFieldNode("PVTSSF_single1", 111, "Status", []map[string]any{ + {"id": "OPT_a", "name": "Todo"}, + }), + iterationFieldNode("PVTIF_iteration1", 222, "Sprint"), + genericFieldNode("PVTF_text1", 333, "Notes", "TEXT"), + })), + ), + ) + gql := githubv4.NewClient(mocked) + + variants := []struct { + fieldName string + expectedType string + wantNodeID string + }{ + {"Status", "SINGLE_SELECT", "PVTSSF_single1"}, + {"Sprint", "ITERATION", "PVTIF_iteration1"}, + {"Notes", "TEXT", "PVTF_text1"}, + } + for _, v := range variants { + t.Run(v.fieldName, func(t *testing.T) { + field, err := resolveProjectFieldByName(context.Background(), gql, "octo-org", "org", 7, v.fieldName, v.expectedType) + require.NoError(t, err) + require.NotNil(t, field) + assert.Equal(t, v.wantNodeID, field.NodeID) + assert.Equal(t, v.expectedType, field.DataType) + }) + } +} + func Test_ResolveProjectFieldByName_NotFound_ReturnsStructuredError(t *testing.T) { mocked := githubv4mock.NewMockedHTTPClient( githubv4mock.NewQueryMatcher( @@ -243,7 +301,7 @@ func (t *requestCountingTransport) RoundTrip(req *http.Request) (*http.Response, return t.inner.RoundTrip(req) } -func Test_ResolveProjectItemIDByIssueNumber_Success(t *testing.T) { +func Test_ResolveProjectItemByIssueNumber_Success(t *testing.T) { mocked := githubv4mock.NewMockedHTTPClient( // project node id lookup (org) githubv4mock.NewQueryMatcher( From 4ed4f816ccbfe52144a98f029d27a4936e426ff4 Mon Sep 17 00:00:00 2001 From: Bryan Zwicker Date: Wed, 22 Jul 2026 10:52:17 -0400 Subject: [PATCH 25/35] Extract aliased project mutation primitive (#2923) * Extract aliased project mutation primitive Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d7dc302d-e6f2-41e9-a2c8-ed598de47067 * Test partial GraphQL mutation data Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4d3df48a-5aa0-4cf0-a067-4aa5618c2887 --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- pkg/github/projects_batch_mutation.go | 132 +++++++++ pkg/github/projects_batch_mutation_test.go | 318 +++++++++++++++++++++ 2 files changed, 450 insertions(+) create mode 100644 pkg/github/projects_batch_mutation.go create mode 100644 pkg/github/projects_batch_mutation_test.go diff --git a/pkg/github/projects_batch_mutation.go b/pkg/github/projects_batch_mutation.go new file mode 100644 index 0000000000..0c478aabf9 --- /dev/null +++ b/pkg/github/projects_batch_mutation.go @@ -0,0 +1,132 @@ +package github + +import ( + "context" + "errors" + "fmt" + "reflect" + "sync" + + "github.com/shurcooL/githubv4" +) + +const batchMutationWireChunkSize = 20 + +type batchMutationKind int + +const ( + batchMutationUpdate batchMutationKind = iota + batchMutationClear +) + +func (k batchMutationKind) fieldName() string { + if k == batchMutationClear { + return "clearProjectV2ItemFieldValue" + } + return "updateProjectV2ItemFieldValue" +} + +type projectV2ItemMutationResult struct { + ProjectV2Item struct { + ID string + FullDatabaseID string `graphql:"fullDatabaseId"` + } `graphql:"projectV2Item"` +} + +type reflectedMutationTypeKey struct { + kind batchMutationKind + size int +} + +var reflectedMutationTypeCache sync.Map + +// Reflected types are cached only by operation and chunk size to bound +// reflect.StructOf's runtime cache; positional names and tags keep request data +// out of type identities. The pinned Client.Mutate binds its third argument to +// $input, so item0 uses $input and later aliases use $input1, $input2, ... +// supplied through the variables map. +func buildAliasedMutationType(kind batchMutationKind, size int) reflect.Type { + key := reflectedMutationTypeKey{kind: kind, size: size} + if cached, ok := reflectedMutationTypeCache.Load(key); ok { + return cached.(reflect.Type) + } + + resultType := reflect.TypeFor[projectV2ItemMutationResult]() + fields := make([]reflect.StructField, size) + for i := range size { + varName := "input" + if i > 0 { + varName = fmt.Sprintf("input%d", i) + } + fields[i] = reflect.StructField{ + Name: fmt.Sprintf("Item%d", i), + Type: resultType, + Tag: reflect.StructTag(fmt.Sprintf(`graphql:"item%d: %s(input: $%s)"`, i, kind.fieldName(), varName)), + } + } + + t := reflect.StructOf(fields) + actual, _ := reflectedMutationTypeCache.LoadOrStore(key, t) + return actual.(reflect.Type) +} + +type mutationAliasOutcome struct { + // Populated confirms this alias returned a project item, even when the + // response also contains GraphQL errors. + Populated bool + NodeID string + FullDatabaseID string +} + +// The pinned client decodes partial data before returning GraphQL errors but +// discards errors[].path. Populated aliases confirm writes; unpopulated aliases +// remain unknown and must not be retried individually. +func executeAliasedMutation(ctx context.Context, gqlClient *githubv4.Client, kind batchMutationKind, inputs []githubv4.Input) ([]mutationAliasOutcome, error) { + if len(inputs) == 0 { + return nil, nil + } + if len(inputs) > batchMutationWireChunkSize { + return nil, fmt.Errorf("internal error: chunk of %d exceeds wire chunk size %d", len(inputs), batchMutationWireChunkSize) + } + + mutationType := buildAliasedMutationType(kind, len(inputs)) + mutationPtr := reflect.New(mutationType) + + var variables map[string]any + if len(inputs) > 1 { + variables = make(map[string]any, len(inputs)-1) + for i := 1; i < len(inputs); i++ { + variables[fmt.Sprintf("input%d", i)] = inputs[i] + } + } + + mutateErr := gqlClient.Mutate(ctx, mutationPtr.Interface(), inputs[0], variables) + + outcomes := make([]mutationAliasOutcome, len(inputs)) + elem := mutationPtr.Elem() + for i := range inputs { + result, ok := elem.Field(i).Interface().(projectV2ItemMutationResult) + if !ok || result.ProjectV2Item.ID == "" { + continue + } + outcomes[i] = mutationAliasOutcome{ + Populated: true, + NodeID: result.ProjectV2Item.ID, + FullDatabaseID: result.ProjectV2Item.FullDatabaseID, + } + } + return outcomes, mutateErr +} + +// The pinned client's GraphQL response error type is unexported; transport and +// decoding failures must remain distinguishable. +func isGraphQLResponseError(err error) bool { + for err != nil { + errType := reflect.TypeOf(err) + if errType.PkgPath() == "github.com/shurcooL/graphql" && errType.Name() == "errors" { + return true + } + err = errors.Unwrap(err) + } + return false +} diff --git a/pkg/github/projects_batch_mutation_test.go b/pkg/github/projects_batch_mutation_test.go new file mode 100644 index 0000000000..749776862d --- /dev/null +++ b/pkg/github/projects_batch_mutation_test.go @@ -0,0 +1,318 @@ +package github + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "reflect" + "strings" + "testing" + + "github.com/shurcooL/githubv4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// capturedGraphQLRequest is one HTTP request observed by sequencedGraphQLTransport. +type capturedGraphQLRequest struct { + Query string + Variables map[string]any +} + +// sequencedGraphQLTransport is a minimal fake http.RoundTripper for exercising +// executeAliasedMutation without needing to hand-construct +// the exact minified GraphQL query text that reflect.StructOf produces: each call +// is served by the next entry in responses, in order, and the parsed query + +// variables are recorded for assertions. +type sequencedGraphQLTransport struct { + t *testing.T + responses []func(req capturedGraphQLRequest) (status int, body string) + calls []capturedGraphQLRequest +} + +func (s *sequencedGraphQLTransport) RoundTrip(req *http.Request) (*http.Response, error) { + raw, err := io.ReadAll(req.Body) + if err != nil { + return nil, err + } + var parsed struct { + Query string `json:"query"` + Variables map[string]any `json:"variables"` + } + if err := json.Unmarshal(raw, &parsed); err != nil { + return nil, err + } + captured := capturedGraphQLRequest{Query: parsed.Query, Variables: parsed.Variables} + s.calls = append(s.calls, captured) + + idx := len(s.calls) - 1 + if idx >= len(s.responses) { + s.t.Fatalf("unexpected GraphQL call #%d (query: %s)", idx, parsed.Query) + } + status, body := s.responses[idx](captured) + return &http.Response{ + StatusCode: status, + Body: io.NopCloser(strings.NewReader(body)), + Header: make(http.Header), + }, nil +} + +type errorGraphQLTransport struct { + err error + calls int +} + +func (t *errorGraphQLTransport) RoundTrip(*http.Request) (*http.Response, error) { + t.calls++ + return nil, t.err +} + +// mutationDataResponse builds a `{"data": {...}}` JSON body with one +// "itemN"."projectV2Item" entry per populated index in ids. +func mutationDataResponse(t *testing.T, ids map[int]struct{ NodeID, FullDatabaseID string }) string { + t.Helper() + data := make(map[string]any, len(ids)) + for i, v := range ids { + data[fmt.Sprintf("item%d", i)] = map[string]any{ + "projectV2Item": map[string]any{ + "id": v.NodeID, + "fullDatabaseId": v.FullDatabaseID, + }, + } + } + body, err := json.Marshal(map[string]any{"data": data}) + require.NoError(t, err) + return string(body) +} + +func mutationErrorResponse(t *testing.T, data map[string]any, message string) string { + t.Helper() + payload := map[string]any{ + "errors": []map[string]any{{"message": message}}, + } + if data != nil { + payload["data"] = data + } + body, err := json.Marshal(payload) + require.NoError(t, err) + return string(body) +} + +func newTestGQLClient(transport http.RoundTripper) *githubv4.Client { + return githubv4.NewClient(&http.Client{Transport: transport}) +} + +func inputsOfSize(n int) []githubv4.Input { + inputs := make([]githubv4.Input, n) + for i := range n { + inputs[i] = githubv4.UpdateProjectV2ItemFieldValueInput{ + ProjectID: githubv4.ID("PVT_project"), + ItemID: githubv4.ID(fmt.Sprintf("PVTI_item%d", i)), + FieldID: githubv4.ID("PVTF_field"), + Value: githubv4.ProjectV2FieldValue{Text: githubv4.NewString("v")}, + } + } + return inputs +} + +func Test_BuildAliasedMutationType_FieldNamesAndTags(t *testing.T) { + for _, size := range []int{1, 2, 20} { + t.Run(fmt.Sprintf("size=%d", size), func(t *testing.T) { + typ := buildAliasedMutationType(batchMutationUpdate, size) + require.Equal(t, size, typ.NumField()) + for i := range size { + field := typ.Field(i) + assert.Equal(t, fmt.Sprintf("Item%d", i), field.Name) + + tag, ok := field.Tag.Lookup("graphql") + require.True(t, ok) + + wantVar := "input" + if i > 0 { + wantVar = fmt.Sprintf("input%d", i) + } + wantTag := fmt.Sprintf("item%d: updateProjectV2ItemFieldValue(input: $%s)", i, wantVar) + assert.Equal(t, wantTag, tag) + + // No owner/id/name/value data may ever appear in the tag: only + // positional aliases and variable references. + assert.NotContains(t, tag, "PVT_") + assert.NotContains(t, tag, "octo") + } + }) + } +} + +func Test_BuildAliasedMutationType_ClearKindUsesClearMutation(t *testing.T) { + typ := buildAliasedMutationType(batchMutationClear, 2) + tag0 := typ.Field(0).Tag.Get("graphql") + tag1 := typ.Field(1).Tag.Get("graphql") + assert.Equal(t, "item0: clearProjectV2ItemFieldValue(input: $input)", tag0) + assert.Equal(t, "item1: clearProjectV2ItemFieldValue(input: $input1)", tag1) +} + +func Test_BuildAliasedMutationType_CachedByKindAndSize(t *testing.T) { + a := buildAliasedMutationType(batchMutationUpdate, 3) + b := buildAliasedMutationType(batchMutationUpdate, 3) + assert.True(t, a == b, "expected the same cached reflect.Type for identical (kind, size)") + + c := buildAliasedMutationType(batchMutationClear, 3) + assert.False(t, a == c, "update and clear must not share a cached type") + + d := buildAliasedMutationType(batchMutationUpdate, 4) + assert.False(t, a == d, "different sizes must not share a cached type") +} + +func Test_ExecuteAliasedMutation_OneAlias(t *testing.T) { + transport := &sequencedGraphQLTransport{ + t: t, + responses: []func(capturedGraphQLRequest) (int, string){ + func(req capturedGraphQLRequest) (int, string) { + // Single alias: the only input is bound positionally via + // Client.Mutate's third argument, so no extra variables map entries. + assert.Len(t, req.Variables, 1) + assert.Contains(t, req.Variables, "input") + return http.StatusOK, mutationDataResponse(t, map[int]struct{ NodeID, FullDatabaseID string }{ + 0: {NodeID: "PVTI_item0", FullDatabaseID: "1001"}, + }) + }, + }, + } + gqlClient := newTestGQLClient(transport) + + outcomes, err := executeAliasedMutation(context.Background(), gqlClient, batchMutationUpdate, inputsOfSize(1)) + require.NoError(t, err) + require.Len(t, outcomes, 1) + assert.True(t, outcomes[0].Populated) + assert.Equal(t, "PVTI_item0", outcomes[0].NodeID) + assert.Equal(t, "1001", outcomes[0].FullDatabaseID) +} + +func Test_ExecuteAliasedMutation_TwoAliases_FirstInputWorkaround(t *testing.T) { + transport := &sequencedGraphQLTransport{ + t: t, + responses: []func(capturedGraphQLRequest) (int, string){ + func(req capturedGraphQLRequest) (int, string) { + // Alias 0's input is always bound to the reserved "input" wire + // variable by Client.Mutate; alias 1's input must be supplied + // separately (as "input1") since a GraphQL variable can only be + // referenced with one value per request. + require.Contains(t, req.Variables, "input1") + require.Contains(t, req.Variables, "input") + return http.StatusOK, mutationDataResponse(t, map[int]struct{ NodeID, FullDatabaseID string }{ + 0: {NodeID: "PVTI_item0", FullDatabaseID: "1001"}, + 1: {NodeID: "PVTI_item1", FullDatabaseID: "1002"}, + }) + }, + }, + } + gqlClient := newTestGQLClient(transport) + + outcomes, err := executeAliasedMutation(context.Background(), gqlClient, batchMutationUpdate, inputsOfSize(2)) + require.NoError(t, err) + require.Len(t, outcomes, 2) + assert.True(t, outcomes[0].Populated) + assert.True(t, outcomes[1].Populated) +} + +func Test_ExecuteAliasedMutation_PreservesPartialDataWithGraphQLErrors(t *testing.T) { + transport := &sequencedGraphQLTransport{ + t: t, + responses: []func(capturedGraphQLRequest) (int, string){ + func(capturedGraphQLRequest) (int, string) { + data := map[string]any{ + "item0": map[string]any{ + "projectV2Item": map[string]any{ + "id": "PVTI_item0", + "fullDatabaseId": "1001", + }, + }, + } + return http.StatusOK, mutationErrorResponse(t, data, "item1 failed") + }, + }, + } + + outcomes, err := executeAliasedMutation(t.Context(), newTestGQLClient(transport), batchMutationUpdate, inputsOfSize(2)) + require.Error(t, err) + assert.True(t, isGraphQLResponseError(err)) + require.Len(t, outcomes, 2) + assert.Equal(t, mutationAliasOutcome{ + Populated: true, + NodeID: "PVTI_item0", + FullDatabaseID: "1001", + }, outcomes[0]) + assert.Equal(t, mutationAliasOutcome{}, outcomes[1]) +} + +func Test_ExecuteAliasedMutation_TwentyAliases(t *testing.T) { + ids := make(map[int]struct{ NodeID, FullDatabaseID string }, 20) + for i := range 20 { + ids[i] = struct{ NodeID, FullDatabaseID string }{ + NodeID: fmt.Sprintf("PVTI_item%d", i), + FullDatabaseID: fmt.Sprintf("%d", 1000+i), + } + } + transport := &sequencedGraphQLTransport{ + t: t, + responses: []func(capturedGraphQLRequest) (int, string){ + func(req capturedGraphQLRequest) (int, string) { + assert.Len(t, req.Variables, 20) // "input" (positional) plus input1..input19 + return http.StatusOK, mutationDataResponse(t, ids) + }, + }, + } + gqlClient := newTestGQLClient(transport) + + outcomes, err := executeAliasedMutation(context.Background(), gqlClient, batchMutationUpdate, inputsOfSize(20)) + require.NoError(t, err) + require.Len(t, outcomes, 20) + for i, oc := range outcomes { + assert.Truef(t, oc.Populated, "outcome %d should be populated", i) + } +} + +func Test_ExecuteAliasedMutation_ChunkSizeExceeded(t *testing.T) { + gqlClient := newTestGQLClient(&sequencedGraphQLTransport{t: t}) + _, err := executeAliasedMutation(context.Background(), gqlClient, batchMutationUpdate, inputsOfSize(21)) + require.Error(t, err) + assert.Contains(t, err.Error(), "exceeds wire chunk size") +} + +func Test_ExecuteAliasedMutation_EmptyInputsIsNoop(t *testing.T) { + gqlClient := newTestGQLClient(&sequencedGraphQLTransport{t: t}) + outcomes, err := executeAliasedMutation(context.Background(), gqlClient, batchMutationUpdate, nil) + require.NoError(t, err) + assert.Nil(t, outcomes) +} + +func Test_ProjectV2ItemMutationResult_ReflectFieldTypeIsConcrete(t *testing.T) { + // executeAliasedMutation type-asserts each reflected field back to + // projectV2ItemMutationResult directly; guard that assumption here. + typ := buildAliasedMutationType(batchMutationUpdate, 1) + assert.Equal(t, reflect.TypeFor[projectV2ItemMutationResult](), typ.Field(0).Type) +} + +func Test_IsGraphQLResponseError(t *testing.T) { + graphqlTransport := &sequencedGraphQLTransport{ + t: t, + responses: []func(capturedGraphQLRequest) (int, string){ + func(_ capturedGraphQLRequest) (int, string) { + return http.StatusOK, mutationErrorResponse(t, nil, "mutation failed") + }, + }, + } + _, graphqlErr := executeAliasedMutation(t.Context(), newTestGQLClient(graphqlTransport), batchMutationUpdate, inputsOfSize(1)) + require.Error(t, graphqlErr) + assert.True(t, isGraphQLResponseError(graphqlErr)) + + transport := &errorGraphQLTransport{err: context.DeadlineExceeded} + _, transportErr := executeAliasedMutation(t.Context(), newTestGQLClient(transport), batchMutationUpdate, inputsOfSize(1)) + require.Error(t, transportErr) + assert.False(t, isGraphQLResponseError(transportErr)) + assert.False(t, isGraphQLResponseError(errors.New("plain error"))) + assert.False(t, isGraphQLResponseError(nil)) +} From d3cd40520f8f6ad0314f92b0e9b71dc473a10ab8 Mon Sep 17 00:00:00 2001 From: Logan Rosen Date: Wed, 22 Jul 2026 11:57:34 -0400 Subject: [PATCH 26/35] build: use patched Go toolchain and UI dependency (#2927) * build: require Go 1.25.12 Ensure setup-go and GoReleaser use the patched Go toolchain for release binaries. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0b2aa497-b47d-464a-b132-af0dcaf2b621 * build(ui): update fast-uri to 3.1.4 Resolve the high-severity host-confusion advisories reported by npm audit. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0b2aa497-b47d-464a-b132-af0dcaf2b621 --------- Co-authored-by: Ross Tarrant --- go.mod | 2 +- ui/package-lock.json | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/go.mod b/go.mod index a6aff1bbe6..aa12211489 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/github/github-mcp-server -go 1.25.0 +go 1.25.12 require ( github.com/go-chi/chi/v5 v5.3.1 diff --git a/ui/package-lock.json b/ui/package-lock.json index 0716e12068..71075e1711 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -2730,9 +2730,9 @@ "peer": true }, "node_modules/fast-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", - "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", + "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", "funding": [ { "type": "github", From e05a38403ff9a394d8cb6677819ccff0354f752f Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Mon, 29 Jun 2026 23:59:40 +0200 Subject: [PATCH 27/35] feat(auth): add GitHub App server-to-server authentication for stdio MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add non-interactive GitHub App installation authentication to the stdio server, so headless deployments (CI, Kubernetes, background agents) can authenticate without a browser, device code, or elicitation. This is the outstanding follow-up tracked in #1333: OAuth login shipped the interactive user-to-server flows, but PEM-based server-to-server auth was still needed to remove the interactive requirement. The new internal/githubapp package signs a short-lived RS256 JWT with the app's private key, exchanges it for an installation access token, and refreshes it transparently before expiry. It exposes a Provider whose AccessToken method mirrors oauth.Manager so it plugs into the existing BearerAuthTransport token provider. Only the standard library and golang.org/x/oauth2 are used. The private key is injected safely: a file path (GITHUB_APP_PRIVATE_KEY_PATH, preferred — mountable as a secret and kept off argv and out of the environment) or an inline GITHUB_APP_PRIVATE_KEY env var. There is intentionally no flag for the key contents, which would otherwise leak via the process command line. App auth is mutually exclusive with a PAT and with OAuth login. A loud startup warning and a dedicated docs page (docs/github-app-auth.md, with Docker and Kubernetes examples) cover the security considerations: this injects a high-privilege credential alongside the agent and is not recommended without an independent security review. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 2 + cmd/github-mcp-server/main.go | 109 ++++++++++- docs/github-app-auth.md | 261 +++++++++++++++++++++++++ docs/oauth-login.md | 7 + internal/ghmcp/oauth_test.go | 43 +++-- internal/ghmcp/server.go | 51 ++++- internal/githubapp/githubapp.go | 275 +++++++++++++++++++++++++++ internal/githubapp/githubapp_test.go | 271 ++++++++++++++++++++++++++ 8 files changed, 995 insertions(+), 24 deletions(-) create mode 100644 docs/github-app-auth.md create mode 100644 internal/githubapp/githubapp.go create mode 100644 internal/githubapp/githubapp_test.go diff --git a/README.md b/README.md index 9a6ef3677a..73bd5f0e53 100644 --- a/README.md +++ b/README.md @@ -311,6 +311,8 @@ Add one of the following JSON blocks to your IDE's MCP settings. See **[Local Server OAuth Login](docs/oauth-login.md)** for the native-binary flow (no fixed port needed), the headless/device-code fallback, GitHub Enterprise Server / `ghe.com`, and bringing your own OAuth or GitHub App. +**Running headless (CI, Kubernetes, background agents)?** The stdio server can authenticate as a **GitHub App installation** with no browser, device code, or elicitation — see **[GitHub App Server-to-Server Authentication](docs/github-app-auth.md)**. This injects a high-privilege credential alongside the agent, so read the security guidance there first; it is not recommended without an independent security review. + **Or authenticate with a Personal Access Token.** Set `GITHUB_PERSONAL_ACCESS_TOKEN` instead (it takes precedence over OAuth): ```json diff --git a/cmd/github-mcp-server/main.go b/cmd/github-mcp-server/main.go index 231b0cf2c3..7629889293 100644 --- a/cmd/github-mcp-server/main.go +++ b/cmd/github-mcp-server/main.go @@ -1,6 +1,7 @@ package main import ( + "context" "errors" "fmt" "os" @@ -9,10 +10,12 @@ import ( "github.com/github/github-mcp-server/internal/buildinfo" "github.com/github/github-mcp-server/internal/ghmcp" + "github.com/github/github-mcp-server/internal/githubapp" "github.com/github/github-mcp-server/internal/oauth" "github.com/github/github-mcp-server/pkg/github" ghhttp "github.com/github/github-mcp-server/pkg/http" ghoauth "github.com/github/github-mcp-server/pkg/http/oauth" + "github.com/github/github-mcp-server/pkg/utils" "github.com/spf13/cobra" "github.com/spf13/pflag" "github.com/spf13/viper" @@ -37,6 +40,17 @@ var ( Long: `Start a server that communicates via standard input/output streams using JSON-RPC messages.`, RunE: func(_ *cobra.Command, _ []string) error { token := viper.GetString("personal_access_token") + + // GitHub App server-to-server auth (non-interactive). It is detected + // when any app-* setting is present; a partial configuration yields a + // clear error from the loader/validator below rather than silently + // falling back to another mode. + appID := viper.GetString("app-id") + appInstallationID := viper.GetString("app-installation-id") + appPrivateKeyPath := viper.GetString("app-private-key-path") + appPrivateKeyInline := viper.GetString("app-private-key") + appAuthRequested := appID != "" || appInstallationID != "" || appPrivateKeyPath != "" || appPrivateKeyInline != "" + oauthClientID := viper.GetString("oauth-client-id") oauthClientSecret := viper.GetString("oauth-client-secret") // Fall back to the build-time baked-in client (official releases) when none is @@ -45,13 +59,20 @@ var ( // --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" { + // id with no secret never picks up the baked-in secret. App auth opts out of this + // default so configuring an app never accidentally enables OAuth login too. + if oauthClientID == "" && !appAuthRequested && oauth.NormalizeHost(viper.GetString("host")) == "https://github.com" { oauthClientID = buildinfo.OAuthClientID oauthClientSecret = buildinfo.OAuthClientSecret } - if token == "" && oauthClientID == "" { - return errors.New("authentication required: set GITHUB_PERSONAL_ACCESS_TOKEN, or pass --oauth-client-id to log in via OAuth") + if token == "" && !appAuthRequested && oauthClientID == "" { + return errors.New("authentication required: set GITHUB_PERSONAL_ACCESS_TOKEN, configure GitHub App auth (GITHUB_APP_ID, GITHUB_APP_INSTALLATION_ID and GITHUB_APP_PRIVATE_KEY_PATH), or pass --oauth-client-id to log in via OAuth") + } + if appAuthRequested && token != "" { + return errors.New("GitHub App authentication and GITHUB_PERSONAL_ACCESS_TOKEN are mutually exclusive: set only one") + } + if appAuthRequested && oauthClientID != "" { + return errors.New("GitHub App authentication and OAuth login (--oauth-client-id) are mutually exclusive: set only one") } // If you're wondering why we're not using viper.GetStringSlice("toolsets"), @@ -116,7 +137,8 @@ var ( // client. The requested scopes default to the full supported set // (which filters out no tools); an explicit, narrower --oauth-scopes // both narrows the grant and hides tools needing other scopes. - if token == "" { + // Skipped for GitHub App auth, which sources tokens non-interactively. + if token == "" && !appAuthRequested { scopes := ghoauth.SupportedScopes if viper.IsSet("oauth-scopes") { if err := viper.UnmarshalKey("oauth-scopes", &scopes); err != nil { @@ -134,6 +156,17 @@ var ( stdioServerConfig.OAuthScopes = scopes } + // GitHub App server-to-server auth: load and parse the private key, + // then resolve the REST base URL so the server can mint installation + // tokens for the configured host (github.com, GHES, or ghe.com). + if appAuthRequested { + appConfig, err := buildAppAuthConfig(appID, appInstallationID, appPrivateKeyPath, appPrivateKeyInline, viper.GetString("host")) + if err != nil { + return err + } + stdioServerConfig.AppAuth = appConfig + } + return ghmcp.RunStdioServer(stdioServerConfig) }, } @@ -230,6 +263,15 @@ func init() { stdioCmd.Flags().StringSlice("oauth-scopes", nil, "Comma-separated OAuth scopes to request; also filters tools to those scopes. Defaults to the full supported set") stdioCmd.Flags().Int("oauth-callback-port", 0, "Fixed local port for the OAuth callback server. Defaults to a random port; set a fixed port when mapping it through Docker") + // stdio-specific GitHub App (server-to-server) flags. Provide an app ID, + // installation ID, and private key to authenticate non-interactively — no + // browser, device code, or elicitation. Intended for headless deployments. + // The private key itself has no flag (only GITHUB_APP_PRIVATE_KEY): a flag + // would place the key in the process arguments. Prefer the key file path. + stdioCmd.Flags().String("app-id", "", "GitHub App ID or client ID, enabling non-interactive server-to-server authentication") + stdioCmd.Flags().String("app-installation-id", "", "GitHub App installation ID to mint installation access tokens for") + stdioCmd.Flags().String("app-private-key-path", "", "Path to the GitHub App private key (PEM). Preferred over GITHUB_APP_PRIVATE_KEY: keeps the key off the command line and out of the environment") + // HTTP-specific flags httpCmd.Flags().Int("port", 8082, "HTTP server port") httpCmd.Flags().String("listen-host", "", "Host the HTTP server binds to (e.g. 127.0.0.1). Empty binds to all interfaces.") @@ -256,6 +298,9 @@ func init() { _ = viper.BindPFlag("oauth-client-secret", stdioCmd.Flags().Lookup("oauth-client-secret")) _ = viper.BindPFlag("oauth-scopes", stdioCmd.Flags().Lookup("oauth-scopes")) _ = viper.BindPFlag("oauth-callback-port", stdioCmd.Flags().Lookup("oauth-callback-port")) + _ = viper.BindPFlag("app-id", stdioCmd.Flags().Lookup("app-id")) + _ = viper.BindPFlag("app-installation-id", stdioCmd.Flags().Lookup("app-installation-id")) + _ = viper.BindPFlag("app-private-key-path", stdioCmd.Flags().Lookup("app-private-key-path")) _ = viper.BindPFlag("port", httpCmd.Flags().Lookup("port")) _ = viper.BindPFlag("listen-host", httpCmd.Flags().Lookup("listen-host")) _ = viper.BindPFlag("base-url", httpCmd.Flags().Lookup("base-url")) @@ -281,6 +326,60 @@ func main() { } } +// buildAppAuthConfig assembles the GitHub App server-to-server configuration: +// it loads and parses the private key and resolves the REST base URL for the +// configured host. The private key is read from a file (preferred) or an inline +// environment value; a missing or partial configuration yields a clear error. +func buildAppAuthConfig(appID, installationID, keyPath, keyInline, host string) (*githubapp.Config, error) { + keyBytes, err := loadAppPrivateKey(keyPath, keyInline) + if err != nil { + return nil, err + } + privateKey, err := githubapp.ParsePrivateKey(keyBytes) + if err != nil { + return nil, fmt.Errorf("invalid GitHub App private key: %w", err) + } + + apiHost, err := utils.NewAPIHost(host) + if err != nil { + return nil, fmt.Errorf("failed to parse host for GitHub App authentication: %w", err) + } + restURL, err := apiHost.BaseRESTURL(context.Background()) + if err != nil { + return nil, fmt.Errorf("failed to resolve REST URL for GitHub App authentication: %w", err) + } + + cfg := &githubapp.Config{ + AppID: appID, + InstallationID: installationID, + PrivateKey: privateKey, + BaseRESTURL: restURL.String(), + } + if err := cfg.Validate(); err != nil { + return nil, err + } + return cfg, nil +} + +// loadAppPrivateKey returns the GitHub App private key bytes from a file path +// (preferred — it keeps the key off argv and out of the environment) or from an +// inline value. The inline form tolerates literal "\n" escapes so a PEM survives +// being carried in a single-line environment variable. +func loadAppPrivateKey(path, inline string) ([]byte, error) { + switch { + case path != "": + data, err := os.ReadFile(path) //#nosec G304 -- operator-supplied path to their own key + if err != nil { + return nil, fmt.Errorf("reading GitHub App private key file: %w", err) + } + return data, nil + case inline != "": + return []byte(strings.ReplaceAll(inline, `\n`, "\n")), nil + default: + return nil, errors.New("GitHub App authentication requires a private key: set GITHUB_APP_PRIVATE_KEY_PATH (preferred) or GITHUB_APP_PRIVATE_KEY") + } +} + func wordSepNormalizeFunc(_ *pflag.FlagSet, name string) pflag.NormalizedName { from := []string{"_"} to := "-" diff --git a/docs/github-app-auth.md b/docs/github-app-auth.md new file mode 100644 index 0000000000..ed9a3db11f --- /dev/null +++ b/docs/github-app-auth.md @@ -0,0 +1,261 @@ +# GitHub App Server-to-Server Authentication (stdio) + +The local (stdio) GitHub MCP Server can authenticate as a **GitHub App +installation** instead of as a user. This is a **server-to-server** (s2s) flow: +the server signs a short-lived JSON Web Token (JWT) with your app's private key, +exchanges it for an installation access token, and refreshes that token +automatically. There is **no browser, no device code, and no elicitation**, so +it works in fully non-interactive environments — CI, Kubernetes, and background +agents such as Copilot's cloud agent. + +> [!WARNING] +> **Read this before you enable it.** This mode was added by popular demand, but +> it is **dangerous** and is **not recommended without an independent security +> review** of your deployment and of this implementation. +> +> - It places a **long-lived, high-privilege credential** (your app's private +> key) in the same environment as an AI agent. Anyone or anything that can read +> that environment can mint tokens that act as your app. +> - Installation access tokens minted here can act across **every repository the +> app is installed on**, with the app's full set of permissions. +> - Exposing credentials to agents — and **especially in the cloud** — is +> inherently risky. Treat this as a break-glass capability and proceed with +> **extreme caution**. +> +> If an interactive login is at all possible for your use case, prefer +> [OAuth login](oauth-login.md) instead, which keeps no long-lived secret next to +> the agent. + +## Contents + +- [When to use this](#when-to-use-this) +- [Why stdio only](#why-stdio-only) +- [How it works](#how-it-works) +- [Prerequisites](#prerequisites) +- [Configuration reference](#configuration-reference) +- [Injecting the private key safely](#injecting-the-private-key-safely) +- [Quick start](#quick-start) +- [Kubernetes](#kubernetes) +- [GitHub Enterprise Server and ghe.com](#github-enterprise-server-and-ghecom) +- [Reducing the blast radius](#reducing-the-blast-radius) +- [Troubleshooting](#troubleshooting) + +## When to use this + +Use GitHub App s2s auth only when **all** of the following hold: + +- The server runs **non-interactively** (no human to complete a browser or + device flow). +- The workload should act as an **organization-managed identity** (the app), + not a single user's Personal Access Token (PAT). +- You have reviewed the security implications above and accept them. + +For everything else, prefer [OAuth login](oauth-login.md) or a +[PAT](https://github.com/settings/personal-access-tokens/new). + +## Why stdio only + +This mode is deliberately limited to the **stdio** server, where the server runs +as a subprocess of a single trusted client and the minted token never crosses +that process boundary. + +It is intentionally **not** available for the `http` server. An HTTP server that +authenticated with a server-wide app identity would let **any** client that can +reach its endpoint act as the app, with the app's full permissions — turning a +network-reachable port into ambient, unauthenticated access to your whole +installation. The `http` server therefore keeps requiring a per-request +`Authorization` token, so every caller's identity and permissions stay explicit. + +If you need a hosted, networked deployment, authenticate callers at the +client/proxy layer and pass per-request tokens; don't give the server a standing +identity. + +## How it works + +1. The server builds a JWT and signs it with your app's private key (RS256). The + JWT is valid for under 10 minutes (GitHub's maximum) and identifies your app. +2. It calls `POST /app/installations/{installation_id}/access_tokens` with that + JWT to obtain an **installation access token** (prefixed `ghs_`), which is + valid for up to one hour. +3. Every GitHub API call uses that token. The server refreshes it about five + minutes before it expires, so long-running sessions keep working without any + intervention. + +The private key is held **in memory only**; the server never writes it or the +minted tokens to disk. + +## Prerequisites + +1. **Register a GitHub App** and generate a **private key** (Settings → your + app → *Private keys* → *Generate a private key*). GitHub downloads a `.pem` + file in PKCS#1 or PKCS#8 format — both are accepted. +2. **Install the app** on the account/organization and grant it the **minimum** + permissions and **only the repositories** it needs (see + [Reducing the blast radius](#reducing-the-blast-radius)). +3. Note three values: + - the **App ID** (or the app's **client ID** — either works as the JWT issuer), + - the **installation ID** (visible in the installation's settings URL, or via + the [installations API](https://docs.github.com/en/rest/apps/apps#list-installations-for-the-authenticated-app)), + - the path to the **private key** `.pem`. + +## Configuration reference + +App auth is enabled when **any** of these `app-*` settings is present; a +partial configuration produces a clear startup error. Settings apply only to the +`stdio` command. + +| Flag | Environment variable | Description | +|------|----------------------|-------------| +| `--app-id` | `GITHUB_APP_ID` | GitHub App ID or client ID. Becomes the JWT issuer. | +| `--app-installation-id` | `GITHUB_APP_INSTALLATION_ID` | Installation ID whose token is minted. | +| `--app-private-key-path` | `GITHUB_APP_PRIVATE_KEY_PATH` | Path to the private key PEM file. **Preferred** way to supply the key. | +| _(no flag)_ | `GITHUB_APP_PRIVATE_KEY` | The PEM contents inline. Use only where a file can't be mounted. Literal `\n` sequences are accepted so the key can live in a single-line variable. | + +There is intentionally **no flag** for the private key contents: a flag would +place the key in the process's command line (`ps`, `/proc//cmdline`), where +other processes could read it. + +App auth is **mutually exclusive** with a PAT (`GITHUB_PERSONAL_ACCESS_TOKEN`) +and with OAuth login (`--oauth-client-id`). Configure exactly one. + +## Injecting the private key safely + +The private key is the most sensitive value in this flow. In order of +preference: + +1. **A mounted secret file** (recommended). Point `GITHUB_APP_PRIVATE_KEY_PATH` + at a file your platform mounts from its secret store — a Kubernetes secret + volume, a Docker secret, or a tmpfs file written by your secret manager. The + key never touches the command line or the process environment. +2. **An inline environment variable** (`GITHUB_APP_PRIVATE_KEY`). Acceptable + where files can't be mounted, but the key is then readable by anything that + can inspect the process environment. Avoid this in shared or cloud + environments. + +Never pass the key on the command line, never bake it into an image, and never +commit it to source control. + +## Quick start + +Native binary, key on disk: + +```bash +github-mcp-server stdio \ + --app-id 123456 \ + --app-installation-id 7891011 \ + --app-private-key-path /secrets/github-app.pem +``` + +Equivalently, with environment variables: + +```bash +export GITHUB_APP_ID=123456 +export GITHUB_APP_INSTALLATION_ID=7891011 +export GITHUB_APP_PRIVATE_KEY_PATH=/secrets/github-app.pem +github-mcp-server stdio +``` + +Docker, mounting the key as a read-only file (preferred over passing it inline): + +```bash +docker run -i --rm \ + -v /secrets/github-app.pem:/secrets/github-app.pem:ro \ + -e GITHUB_APP_ID=123456 \ + -e GITHUB_APP_INSTALLATION_ID=7891011 \ + -e GITHUB_APP_PRIVATE_KEY_PATH=/secrets/github-app.pem \ + ghcr.io/github/github-mcp-server +``` + +## Kubernetes + +Store the key in a `Secret` and mount it as a file; pass the IDs as environment +variables. This keeps the key off the command line and out of the container's +environment. + +```yaml +apiVersion: v1 +kind: Secret +metadata: + name: github-app +type: Opaque +stringData: + private-key.pem: | + -----BEGIN RSA PRIVATE KEY----- + ... + -----END RSA PRIVATE KEY----- +--- +apiVersion: v1 +kind: Pod +metadata: + name: github-mcp-server +spec: + containers: + - name: github-mcp-server + image: ghcr.io/github/github-mcp-server + stdin: true + env: + - name: GITHUB_APP_ID + value: "123456" + - name: GITHUB_APP_INSTALLATION_ID + value: "7891011" + - name: GITHUB_APP_PRIVATE_KEY_PATH + value: /secrets/github-app/private-key.pem + volumeMounts: + - name: github-app + mountPath: /secrets/github-app + readOnly: true + volumes: + - name: github-app + secret: + secretName: github-app +``` + +## GitHub Enterprise Server and ghe.com + +Set the host with `--gh-host` / `GITHUB_HOST`; the server derives the correct +installation token endpoint from it, so tokens are minted against your instance +rather than github.com. Register the app and generate its key on that same host. + +```bash +github-mcp-server stdio \ + --gh-host https://github.example.com \ + --app-id 123456 \ + --app-installation-id 7891011 \ + --app-private-key-path /secrets/github-app.pem +``` + +- For GitHub Enterprise Server, prefix the host with `https://`. +- For `ghe.com`, use `https://YOURSUBDOMAIN.ghe.com`. + +## Reducing the blast radius + +Because the minted token can act across the whole installation, minimize what it +can do: + +- **Grant least privilege.** Enable only the app permissions the workload needs, + and prefer read-only where possible. +- **Scope the installation to specific repositories** rather than *All + repositories*. +- **Rotate the private key** periodically and immediately if it may have been + exposed (Settings → your app → *Private keys*). +- **Isolate the runtime.** Run the server where only trusted code shares its + process environment and mounted secrets. +- **Combine with `--read-only` and toolset/scoping flags** to further narrow + what the agent can invoke. See the + [Server Configuration Guide](server-configuration.md). + +## Troubleshooting + +- **`GitHub App authentication requires a private key`** — you set some `app-*` + values but no key. Set `GITHUB_APP_PRIVATE_KEY_PATH` (preferred) or + `GITHUB_APP_PRIVATE_KEY`. +- **`invalid GitHub App private key`** — the PEM could not be parsed. Ensure it + is the app's RSA private key in PKCS#1 or PKCS#8 form and was not truncated + (when inline, encode newlines as literal `\n`). +- **`installation token request failed: 401`** — usually a clock-skew problem or + the wrong App ID/key pairing. Check the host clock and that the key belongs to + the configured app. +- **`installation token request failed: 404`** — the installation ID is wrong, + or the app is not installed where you think. Re-check the installation ID. +- **`... and GITHUB_PERSONAL_ACCESS_TOKEN are mutually exclusive`** — a PAT is + also set in the environment. Unset it; choose exactly one auth mode. diff --git a/docs/oauth-login.md b/docs/oauth-login.md index 16c5dab67e..31a0c90dce 100644 --- a/docs/oauth-login.md +++ b/docs/oauth-login.md @@ -15,6 +15,13 @@ pass `--oauth-client-id` (see [Bring your own app](#bring-your-own-app)). > `http` command have their own authentication; see > [Remote Server](remote-server.md). +> **Running non-interactively?** OAuth still needs a human to complete the flow +> once. For fully headless deployments (CI, Kubernetes, background agents), +> authenticate as a GitHub App installation instead — see +> [GitHub App Server-to-Server Authentication](github-app-auth.md). Note the +> security warnings there: it keeps a high-privilege credential next to the +> agent and is not recommended without an independent security review. + ## Contents - [How it works](#how-it-works) diff --git a/internal/ghmcp/oauth_test.go b/internal/ghmcp/oauth_test.go index ca9b6177ac..46c62d1156 100644 --- a/internal/ghmcp/oauth_test.go +++ b/internal/ghmcp/oauth_test.go @@ -9,6 +9,7 @@ import ( "net/http/httptest" "testing" + "github.com/github/github-mcp-server/internal/githubapp" "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" @@ -538,20 +539,40 @@ func TestOAuthMultiRoundTripResultType(t *testing.T) { assert.False(t, toolRan) } -// 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) { +// TestRunStdioServerRejectsMultipleAuthModes verifies the mutually-exclusive +// guard: supplying more than one of a static token, an OAuth manager, or GitHub +// App auth is rejected before the server starts, rather than silently preferring +// one for auth and another for scope filtering. +func TestRunStdioServerRejectsMultipleAuthModes(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") + + tests := []struct { + name string + cfg StdioServerConfig + }{ + { + name: "token and oauth", + cfg: StdioServerConfig{Token: "ghp_static", OAuthManager: mgr}, + }, + { + name: "token and app", + cfg: StdioServerConfig{Token: "ghp_static", AppAuth: &githubapp.Config{}}, + }, + { + name: "oauth and app", + cfg: StdioServerConfig{OAuthManager: mgr, AppAuth: &githubapp.Config{}}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := RunStdioServer(tt.cfg) + require.Error(t, err) + assert.Contains(t, err.Error(), "exactly one authentication mode") + }) + } } // TestCreateGitHubClientsTokenProvider proves the OAuth wiring: when a diff --git a/internal/ghmcp/server.go b/internal/ghmcp/server.go index 1e0611d648..67c3b067e7 100644 --- a/internal/ghmcp/server.go +++ b/internal/ghmcp/server.go @@ -12,6 +12,7 @@ import ( "syscall" "time" + "github.com/github/github-mcp-server/internal/githubapp" "github.com/github/github-mcp-server/internal/oauth" "github.com/github/github-mcp-server/pkg/errors" "github.com/github/github-mcp-server/pkg/github" @@ -257,15 +258,31 @@ type StdioServerConfig struct { // are hidden. The default set is the full supported list, which hides // nothing; an explicit, narrower list filters accordingly. OAuthScopes []string + + // AppAuth, when non-nil, enables non-interactive GitHub App server-to-server + // authentication: the server mints and transparently refreshes installation + // access tokens from the app's private key, with no browser, device code, or + // elicitation. It suits headless deployments (CI, Kubernetes, background + // agents). It is mutually exclusive with a static Token and with + // OAuthManager. See internal/githubapp and docs/github-app-auth.md — this + // injects a high-privilege credential alongside the agent and should not be + // used without an independent security review. + AppAuth *githubapp.Config } // RunStdioServer is not concurrent safe. func RunStdioServer(cfg StdioServerConfig) error { - // OAuth login and a static token are mutually exclusive: they would - // disagree on how the token is sourced (lazy provider vs. static) and on - // scope filtering, so reject the ambiguous combination up front. - if cfg.OAuthManager != nil && cfg.Token != "" { - return fmt.Errorf("OAuthManager and a static Token are mutually exclusive: provide one or the other") + // A static token, OAuth login, and GitHub App auth are mutually exclusive: + // they disagree on how the token is sourced (static vs. lazy provider) and + // on scope filtering, so reject any ambiguous combination up front. + authModes := 0 + for _, on := range []bool{cfg.Token != "", cfg.OAuthManager != nil, cfg.AppAuth != nil} { + if on { + authModes++ + } + } + if authModes > 1 { + return fmt.Errorf("choose exactly one authentication mode: a static Token, OAuthManager (OAuth login), or AppAuth (GitHub App)") } // Create app context @@ -290,6 +307,20 @@ func RunStdioServer(cfg StdioServerConfig) error { logger := slog.New(slogHandler) logger.Info("starting server", "version", cfg.Version, "host", cfg.Host, "readOnly", cfg.ReadOnly, "lockdownEnabled", cfg.LockdownMode) + // GitHub App server-to-server auth mints installation tokens with no human + // in the loop. Build the provider here so it can use the configured logger. + var appProvider *githubapp.Provider + if cfg.AppAuth != nil { + // Surfaced loudly because this injects a high-privilege credential next + // to the agent; the detailed guidance lives in docs/github-app-auth.md. + logger.Warn("GitHub App server-to-server authentication is enabled; installation tokens minted here can act across every repository the app is installed on — review docs/github-app-auth.md and prefer least-privilege, repository-scoped installations") + provider, err := githubapp.NewProvider(*cfg.AppAuth, logger) + if err != nil { + return fmt.Errorf("failed to configure GitHub App authentication: %w", err) + } + appProvider = provider + } + // 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 @@ -311,13 +342,17 @@ func RunStdioServer(cfg StdioServerConfig) error { 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. + // For OAuth or GitHub App auth, the token is resolved lazily by a provider: + // empty until the user authorizes (OAuth) or minted on demand and refreshed + // (App). A static PAT, by contrast, is passed through unchanged. var tokenProvider func() string var toolHandlerMiddleware []inventory.ToolHandlerMiddleware - if cfg.OAuthManager != nil { + switch { + case cfg.OAuthManager != nil: tokenProvider = cfg.OAuthManager.AccessToken toolHandlerMiddleware = append(toolHandlerMiddleware, createOAuthToolMiddleware(cfg.OAuthManager, logger)) + case appProvider != nil: + tokenProvider = appProvider.AccessToken } ghServer, err := NewStdioMCPServer(ctx, github.MCPServerConfig{ diff --git a/internal/githubapp/githubapp.go b/internal/githubapp/githubapp.go new file mode 100644 index 0000000000..49072b93f0 --- /dev/null +++ b/internal/githubapp/githubapp.go @@ -0,0 +1,275 @@ +// Package githubapp implements non-interactive GitHub App server-to-server +// (s2s) authentication for the stdio server. +// +// Unlike the user-to-server OAuth flows in internal/oauth, this requires no +// human: no browser, no device code, no elicitation. It signs a short-lived +// JWT with the app's private key, exchanges it for an installation access +// token, and transparently refreshes that token before it expires. That makes +// it suitable for headless deployments — CI, Kubernetes, background agents. +// +// It only depends on the standard library and golang.org/x/oauth2. +// +// # Security +// +// This mode injects a long-lived, high-privilege credential (the app private +// key) into an environment shared with an AI agent, and the installation +// tokens it mints can act across every repository the app is installed on. It +// was added by popular demand for non-interactive deployments, but exposing +// credentials to agents — especially in the cloud — is dangerous and is not +// recommended without an independent security review. See +// docs/github-app-auth.md for the full guidance and least-privilege advice. +package githubapp + +import ( + "context" + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "net/url" + "os" + "strings" + "sync" + "time" + + "golang.org/x/oauth2" +) + +const ( + // jwtLifetime is how long minted app JWTs are valid. GitHub rejects app JWTs + // whose exp is more than 10 minutes in the future; 9 minutes leaves headroom. + jwtLifetime = 9 * time.Minute + + // clockSkew backdates the JWT iat to tolerate small clock differences + // between this host and GitHub, which would otherwise reject the JWT. + clockSkew = 60 * time.Second + + // refreshBuffer refreshes installation tokens this long before their real + // expiry so an in-flight request never races the expiry boundary. + refreshBuffer = 5 * time.Minute + + // httpTimeout bounds each call to the installation token endpoint so a + // stalled GitHub API cannot block a tool call indefinitely. + httpTimeout = 30 * time.Second +) + +// Config describes a GitHub App installation used for server-to-server auth. +type Config struct { + // AppID is the GitHub App's App ID or client ID; it becomes the JWT issuer + // (iss). Both forms are accepted by GitHub. + AppID string + + // InstallationID identifies the installation whose access token is minted. + InstallationID string + + // PrivateKey signs the app JWT (RS256). Parse one with ParsePrivateKey. + PrivateKey *rsa.PrivateKey + + // BaseRESTURL is the REST API base, e.g. https://api.github.com/ for + // github.com or https://HOST/api/v3/ for GitHub Enterprise Server. + BaseRESTURL string +} + +// Validate reports whether the configuration is complete enough to mint tokens. +func (c Config) Validate() error { + switch { + case c.AppID == "": + return errors.New("GitHub App ID is required (GITHUB_APP_ID)") + case c.InstallationID == "": + return errors.New("GitHub App installation ID is required (GITHUB_APP_INSTALLATION_ID)") + case c.PrivateKey == nil: + return errors.New("GitHub App private key is required (GITHUB_APP_PRIVATE_KEY_PATH or GITHUB_APP_PRIVATE_KEY)") + case c.BaseRESTURL == "": + return errors.New("GitHub App REST base URL is required") + } + return nil +} + +// ParsePrivateKey parses a PEM-encoded RSA private key in PKCS#1 ("RSA PRIVATE +// KEY") or PKCS#8 ("PRIVATE KEY") form — the two formats GitHub issues for app +// keys. +func ParsePrivateKey(pemBytes []byte) (*rsa.PrivateKey, error) { + block, _ := pem.Decode(pemBytes) + if block == nil { + return nil, errors.New("no PEM block found in private key") + } + if key, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil { + return key, nil + } + parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("parsing private key (want PKCS#1 or PKCS#8 RSA): %w", err) + } + key, ok := parsed.(*rsa.PrivateKey) + if !ok { + return nil, fmt.Errorf("private key is %T, want an RSA key", parsed) + } + return key, nil +} + +// mintJWT builds and signs a short-lived app JWT (RS256) for the configured +// app, as required by the installation token endpoint. +func (c Config) mintJWT(now time.Time) (string, error) { + header := map[string]string{"alg": "RS256", "typ": "JWT"} + claims := map[string]any{ + "iat": now.Add(-clockSkew).Unix(), + "exp": now.Add(jwtLifetime).Unix(), + "iss": c.AppID, + } + + headerJSON, err := json.Marshal(header) + if err != nil { + return "", fmt.Errorf("encoding JWT header: %w", err) + } + claimsJSON, err := json.Marshal(claims) + if err != nil { + return "", fmt.Errorf("encoding JWT claims: %w", err) + } + + signingInput := base64.RawURLEncoding.EncodeToString(headerJSON) + "." + + base64.RawURLEncoding.EncodeToString(claimsJSON) + + digest := sha256.Sum256([]byte(signingInput)) + signature, err := rsa.SignPKCS1v15(rand.Reader, c.PrivateKey, crypto.SHA256, digest[:]) + if err != nil { + return "", fmt.Errorf("signing JWT: %w", err) + } + + return signingInput + "." + base64.RawURLEncoding.EncodeToString(signature), nil +} + +// installationTokenSource is an oauth2.TokenSource that mints GitHub App +// installation access tokens. It performs no caching itself; wrap it in +// oauth2.ReuseTokenSource (see NewProvider) for that. +type installationTokenSource struct { + cfg Config + httpClient *http.Client +} + +func newInstallationTokenSource(cfg Config, httpClient *http.Client) *installationTokenSource { + if httpClient == nil { + httpClient = &http.Client{Timeout: httpTimeout} + } + return &installationTokenSource{cfg: cfg, httpClient: httpClient} +} + +// Token mints a fresh installation access token. The returned token's Expiry is +// set refreshBuffer before the real expiry so callers refresh early. +func (s *installationTokenSource) Token() (*oauth2.Token, error) { + jwt, err := s.cfg.mintJWT(time.Now()) + if err != nil { + return nil, err + } + + endpoint, err := url.JoinPath(s.cfg.BaseRESTURL, "app", "installations", s.cfg.InstallationID, "access_tokens") + if err != nil { + return nil, fmt.Errorf("building installation token URL: %w", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), httpTimeout) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, nil) + if err != nil { + return nil, fmt.Errorf("creating installation token request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+jwt) + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("X-GitHub-Api-Version", "2022-11-28") + + resp, err := s.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("requesting installation token: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusCreated { + // The error body is GitHub's JSON message (never the token); include a + // bounded snippet to make misconfiguration diagnosable. + snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + return nil, fmt.Errorf("installation token request failed: %s: %s", resp.Status, strings.TrimSpace(string(snippet))) + } + + var body struct { + Token string `json:"token"` + ExpiresAt time.Time `json:"expires_at"` + } + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + return nil, fmt.Errorf("decoding installation token response: %w", err) + } + if body.Token == "" { + return nil, errors.New("installation token response did not contain a token") + } + + expiry := body.ExpiresAt + if !expiry.IsZero() { + expiry = expiry.Add(-refreshBuffer) + } + return &oauth2.Token{ + AccessToken: body.Token, + TokenType: "token", + Expiry: expiry, + }, nil +} + +// Provider supplies GitHub App installation access tokens, caching and +// refreshing them transparently. Its AccessToken method mirrors +// oauth.Manager.AccessToken so it can back BearerAuthTransport.TokenProvider. +type Provider struct { + source oauth2.TokenSource + logger *slog.Logger + + mu sync.Mutex + errLogged bool +} + +// NewProvider validates cfg and returns a Provider that mints and refreshes +// installation tokens. A nil logger logs to stderr. +func NewProvider(cfg Config, logger *slog.Logger) (*Provider, error) { + if err := cfg.Validate(); err != nil { + return nil, err + } + if logger == nil { + logger = slog.New(slog.NewTextHandler(os.Stderr, nil)) + } + // ReuseTokenSource caches the token and only calls the underlying source + // once the cached token is expired. Because Token() backdates Expiry by + // refreshBuffer, that refresh happens ~5 minutes before the real expiry. + source := oauth2.ReuseTokenSource(nil, newInstallationTokenSource(cfg, nil)) + return &Provider{source: source, logger: logger}, nil +} + +// AccessToken returns a currently valid installation access token, refreshing +// it if needed, or "" if a token could not be obtained. A fetch failure is +// logged once (until the next success) so a misconfiguration is visible without +// flooding the log on every tool call. +func (p *Provider) AccessToken() string { + tok, err := p.source.Token() + if err != nil { + p.mu.Lock() + if !p.errLogged { + p.errLogged = true + p.logger.Error("failed to obtain GitHub App installation token", "error", err) + } + p.mu.Unlock() + return "" + } + p.mu.Lock() + p.errLogged = false + p.mu.Unlock() + return tok.AccessToken +} + +// HasToken reports whether a valid token can currently be obtained. +func (p *Provider) HasToken() bool { + return p.AccessToken() != "" +} diff --git a/internal/githubapp/githubapp_test.go b/internal/githubapp/githubapp_test.go new file mode 100644 index 0000000000..c2d5eeff7f --- /dev/null +++ b/internal/githubapp/githubapp_test.go @@ -0,0 +1,271 @@ +package githubapp + +import ( + "bytes" + "crypto" + "crypto/ed25519" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "fmt" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newTestKey(t *testing.T) *rsa.PrivateKey { + t.Helper() + key, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + return key +} + +func pkcs1PEM(t *testing.T, key *rsa.PrivateKey) []byte { + t.Helper() + return pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}) +} + +func pkcs8PEM(t *testing.T, key *rsa.PrivateKey) []byte { + t.Helper() + der, err := x509.MarshalPKCS8PrivateKey(key) + require.NoError(t, err) + return pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}) +} + +func TestParsePrivateKey(t *testing.T) { + key := newTestKey(t) + + t.Run("PKCS1", func(t *testing.T) { + got, err := ParsePrivateKey(pkcs1PEM(t, key)) + require.NoError(t, err) + assert.Equal(t, key.N, got.N) + }) + + t.Run("PKCS8", func(t *testing.T) { + got, err := ParsePrivateKey(pkcs8PEM(t, key)) + require.NoError(t, err) + assert.Equal(t, key.N, got.N) + }) + + t.Run("not PEM", func(t *testing.T) { + _, err := ParsePrivateKey([]byte("not a pem")) + require.Error(t, err) + assert.Contains(t, err.Error(), "no PEM block") + }) + + t.Run("non-RSA key", func(t *testing.T) { + _, priv, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + der, err := x509.MarshalPKCS8PrivateKey(priv) + require.NoError(t, err) + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}) + + _, err = ParsePrivateKey(keyPEM) + require.Error(t, err) + assert.Contains(t, err.Error(), "want an RSA key") + }) +} + +func TestConfigValidate(t *testing.T) { + key := newTestKey(t) + base := Config{AppID: "123", InstallationID: "456", PrivateKey: key, BaseRESTURL: "https://api.github.com/"} + require.NoError(t, base.Validate()) + + tests := []struct { + name string + mutate func(c *Config) + want string + }{ + {"missing app id", func(c *Config) { c.AppID = "" }, "App ID is required"}, + {"missing installation id", func(c *Config) { c.InstallationID = "" }, "installation ID is required"}, + {"missing private key", func(c *Config) { c.PrivateKey = nil }, "private key is required"}, + {"missing base url", func(c *Config) { c.BaseRESTURL = "" }, "REST base URL is required"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := base + tt.mutate(&c) + err := c.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), tt.want) + }) + } +} + +// verifyJWT parses and verifies an app JWT against the public key and returns +// its claims, asserting the structural requirements GitHub enforces. +func verifyJWT(t *testing.T, token string, pub *rsa.PublicKey) map[string]any { + t.Helper() + parts := strings.Split(token, ".") + require.Len(t, parts, 3, "JWT must have three segments") + + headerJSON, err := base64.RawURLEncoding.DecodeString(parts[0]) + require.NoError(t, err) + var header map[string]string + require.NoError(t, json.Unmarshal(headerJSON, &header)) + assert.Equal(t, "RS256", header["alg"]) + assert.Equal(t, "JWT", header["typ"]) + + signingInput := parts[0] + "." + parts[1] + digest := sha256.Sum256([]byte(signingInput)) + signature, err := base64.RawURLEncoding.DecodeString(parts[2]) + require.NoError(t, err) + require.NoError(t, rsa.VerifyPKCS1v15(pub, crypto.SHA256, digest[:], signature), "signature must verify") + + claimsJSON, err := base64.RawURLEncoding.DecodeString(parts[1]) + require.NoError(t, err) + var claims map[string]any + require.NoError(t, json.Unmarshal(claimsJSON, &claims)) + return claims +} + +func TestMintJWT(t *testing.T) { + key := newTestKey(t) + cfg := Config{AppID: "my-app-id", PrivateKey: key} + + now := time.Now() + token, err := cfg.mintJWT(now) + require.NoError(t, err) + + claims := verifyJWT(t, token, &key.PublicKey) + assert.Equal(t, "my-app-id", claims["iss"]) + + iat := int64(claims["iat"].(float64)) + exp := int64(claims["exp"].(float64)) + assert.Equal(t, now.Add(-clockSkew).Unix(), iat, "iat should be backdated by the clock skew") + assert.Equal(t, now.Add(jwtLifetime).Unix(), exp) + assert.LessOrEqual(t, exp-iat, int64((10 * time.Minute).Seconds()), "JWT must live no longer than GitHub's 10 minute cap") +} + +// installationServer is a fake installation token endpoint that verifies the +// app JWT and returns a token expiring at expiresAt. It counts mint requests. +func installationServer(t *testing.T, pub *rsa.PublicKey, token string, expiresAt time.Time) (*httptest.Server, *atomic.Int32) { + t.Helper() + var calls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/app/installations/456/access_tokens", r.URL.Path) + + authz := r.Header.Get("Authorization") + require.True(t, strings.HasPrefix(authz, "Bearer "), "must send the app JWT as a bearer token") + verifyJWT(t, strings.TrimPrefix(authz, "Bearer "), pub) + + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(map[string]any{ + "token": token, + "expires_at": expiresAt.UTC().Format(time.RFC3339), + }) + })) + t.Cleanup(srv.Close) + return srv, &calls +} + +func newTestConfig(key *rsa.PrivateKey, baseURL string) Config { + return Config{AppID: "123", InstallationID: "456", PrivateKey: key, BaseRESTURL: baseURL + "/"} +} + +func TestProviderFetchesToken(t *testing.T) { + key := newTestKey(t) + srv, calls := installationServer(t, &key.PublicKey, "ghs_fresh", time.Now().Add(time.Hour)) + + provider, err := NewProvider(newTestConfig(key, srv.URL), slog.New(slog.NewTextHandler(&bytes.Buffer{}, nil))) + require.NoError(t, err) + + assert.Equal(t, "ghs_fresh", provider.AccessToken()) + assert.True(t, provider.HasToken()) + assert.Equal(t, int32(1), calls.Load()) +} + +func TestProviderCachesToken(t *testing.T) { + key := newTestKey(t) + srv, calls := installationServer(t, &key.PublicKey, "ghs_cached", time.Now().Add(time.Hour)) + + provider, err := NewProvider(newTestConfig(key, srv.URL), slog.New(slog.NewTextHandler(&bytes.Buffer{}, nil))) + require.NoError(t, err) + + for range 3 { + assert.Equal(t, "ghs_cached", provider.AccessToken()) + } + assert.Equal(t, int32(1), calls.Load(), "a token valid for an hour should be minted only once") +} + +func TestProviderRefreshesNearExpiry(t *testing.T) { + key := newTestKey(t) + // expires within the refresh buffer, so the stored expiry is already in the + // past and every call re-mints. + srv, calls := installationServer(t, &key.PublicKey, "ghs_short", time.Now().Add(refreshBuffer-time.Minute)) + + provider, err := NewProvider(newTestConfig(key, srv.URL), slog.New(slog.NewTextHandler(&bytes.Buffer{}, nil))) + require.NoError(t, err) + + assert.Equal(t, "ghs_short", provider.AccessToken()) + assert.Equal(t, "ghs_short", provider.AccessToken()) + assert.Equal(t, int32(2), calls.Load(), "a token expiring within the refresh buffer should re-mint each call") +} + +func TestProviderErrorLoggedOnce(t *testing.T) { + key := newTestKey(t) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"message":"A JSON web token could not be decoded"}`)) + })) + t.Cleanup(srv.Close) + + var logBuf bytes.Buffer + logger := slog.New(slog.NewTextHandler(&logBuf, nil)) + provider, err := NewProvider(newTestConfig(key, srv.URL), logger) + require.NoError(t, err) + + assert.Empty(t, provider.AccessToken()) + assert.Empty(t, provider.AccessToken()) + assert.Equal(t, 1, strings.Count(logBuf.String(), "failed to obtain GitHub App installation token"), + "a repeated fetch failure should only be logged once") +} + +func TestProviderErrorIncludesStatus(t *testing.T) { + key := newTestKey(t) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message":"Not Found"}`)) + })) + t.Cleanup(srv.Close) + + source := newInstallationTokenSource(newTestConfig(key, srv.URL), srv.Client()) + _, err := source.Token() + require.Error(t, err) + assert.Contains(t, err.Error(), "404") + assert.Contains(t, err.Error(), "Not Found") +} + +func TestNewProviderValidates(t *testing.T) { + _, err := NewProvider(Config{}, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "App ID is required") +} + +// Ensure the source returns an error rather than panicking on a token-less 201. +func TestSourceRejectsEmptyToken(t *testing.T) { + key := newTestKey(t) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusCreated) + _, _ = fmt.Fprint(w, `{"expires_at":"2099-01-01T00:00:00Z"}`) + })) + t.Cleanup(srv.Close) + + source := newInstallationTokenSource(newTestConfig(key, srv.URL), srv.Client()) + _, err := source.Token() + require.Error(t, err) + assert.Contains(t, err.Error(), "did not contain a token") +} From ea4e3960b8a8466f0c8af71e464882dd79526465 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Wed, 22 Jul 2026 15:36:23 +0200 Subject: [PATCH 28/35] refactor(auth): isolate GitHub App auth to stdio startup Keep PEM loading and installation-token provider construction at the CLI leaf, then pass a generic refreshing token provider through the existing HTTP transports. Rebase the feature onto current main and keep the HTTP command unchanged. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 646357dd-c89f-4973-9a5c-e6c5fc18818c --- README.md | 2 +- cmd/github-mcp-server/main.go | 50 ++---- cmd/github-mcp-server/main_test.go | 38 ++++ docs/github-app-auth.md | 254 ++++----------------------- docs/oauth-login.md | 8 +- internal/ghmcp/oauth_test.go | 19 +- internal/ghmcp/server.go | 45 +---- internal/githubapp/githubapp.go | 122 ++++--------- internal/githubapp/githubapp_test.go | 77 +++++--- pkg/github/server.go | 3 +- pkg/http/transport/bearer.go | 6 +- 11 files changed, 183 insertions(+), 441 deletions(-) create mode 100644 cmd/github-mcp-server/main_test.go diff --git a/README.md b/README.md index 73bd5f0e53..1a06c0697d 100644 --- a/README.md +++ b/README.md @@ -311,7 +311,7 @@ Add one of the following JSON blocks to your IDE's MCP settings. See **[Local Server OAuth Login](docs/oauth-login.md)** for the native-binary flow (no fixed port needed), the headless/device-code fallback, GitHub Enterprise Server / `ghe.com`, and bringing your own OAuth or GitHub App. -**Running headless (CI, Kubernetes, background agents)?** The stdio server can authenticate as a **GitHub App installation** with no browser, device code, or elicitation — see **[GitHub App Server-to-Server Authentication](docs/github-app-auth.md)**. This injects a high-privilege credential alongside the agent, so read the security guidance there first; it is not recommended without an independent security review. +For non-interactive stdio deployments, see **[GitHub App Authentication](docs/github-app-auth.md)**. **Or authenticate with a Personal Access Token.** Set `GITHUB_PERSONAL_ACCESS_TOKEN` instead (it takes precedence over OAuth): diff --git a/cmd/github-mcp-server/main.go b/cmd/github-mcp-server/main.go index 7629889293..7671706b57 100644 --- a/cmd/github-mcp-server/main.go +++ b/cmd/github-mcp-server/main.go @@ -40,11 +40,6 @@ var ( Long: `Start a server that communicates via standard input/output streams using JSON-RPC messages.`, RunE: func(_ *cobra.Command, _ []string) error { token := viper.GetString("personal_access_token") - - // GitHub App server-to-server auth (non-interactive). It is detected - // when any app-* setting is present; a partial configuration yields a - // clear error from the loader/validator below rather than silently - // falling back to another mode. appID := viper.GetString("app-id") appInstallationID := viper.GetString("app-installation-id") appPrivateKeyPath := viper.GetString("app-private-key-path") @@ -59,14 +54,13 @@ var ( // --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. App auth opts out of this - // default so configuring an app never accidentally enables OAuth login too. + // id with no secret never picks up the baked-in secret. if oauthClientID == "" && !appAuthRequested && oauth.NormalizeHost(viper.GetString("host")) == "https://github.com" { oauthClientID = buildinfo.OAuthClientID oauthClientSecret = buildinfo.OAuthClientSecret } if token == "" && !appAuthRequested && oauthClientID == "" { - return errors.New("authentication required: set GITHUB_PERSONAL_ACCESS_TOKEN, configure GitHub App auth (GITHUB_APP_ID, GITHUB_APP_INSTALLATION_ID and GITHUB_APP_PRIVATE_KEY_PATH), or pass --oauth-client-id to log in via OAuth") + return errors.New("authentication required: set GITHUB_PERSONAL_ACCESS_TOKEN, configure GitHub App auth, or pass --oauth-client-id to log in via OAuth") } if appAuthRequested && token != "" { return errors.New("GitHub App authentication and GITHUB_PERSONAL_ACCESS_TOKEN are mutually exclusive: set only one") @@ -137,7 +131,6 @@ var ( // client. The requested scopes default to the full supported set // (which filters out no tools); an explicit, narrower --oauth-scopes // both narrows the grant and hides tools needing other scopes. - // Skipped for GitHub App auth, which sources tokens non-interactively. if token == "" && !appAuthRequested { scopes := ghoauth.SupportedScopes if viper.IsSet("oauth-scopes") { @@ -156,15 +149,12 @@ var ( stdioServerConfig.OAuthScopes = scopes } - // GitHub App server-to-server auth: load and parse the private key, - // then resolve the REST base URL so the server can mint installation - // tokens for the configured host (github.com, GHES, or ghe.com). if appAuthRequested { - appConfig, err := buildAppAuthConfig(appID, appInstallationID, appPrivateKeyPath, appPrivateKeyInline, viper.GetString("host")) + tokenProvider, err := newGitHubAppTokenProvider(appID, appInstallationID, appPrivateKeyPath, appPrivateKeyInline, viper.GetString("host")) if err != nil { return err } - stdioServerConfig.AppAuth = appConfig + stdioServerConfig.TokenProvider = tokenProvider } return ghmcp.RunStdioServer(stdioServerConfig) @@ -263,11 +253,7 @@ func init() { stdioCmd.Flags().StringSlice("oauth-scopes", nil, "Comma-separated OAuth scopes to request; also filters tools to those scopes. Defaults to the full supported set") stdioCmd.Flags().Int("oauth-callback-port", 0, "Fixed local port for the OAuth callback server. Defaults to a random port; set a fixed port when mapping it through Docker") - // stdio-specific GitHub App (server-to-server) flags. Provide an app ID, - // installation ID, and private key to authenticate non-interactively — no - // browser, device code, or elicitation. Intended for headless deployments. - // The private key itself has no flag (only GITHUB_APP_PRIVATE_KEY): a flag - // would place the key in the process arguments. Prefer the key file path. + // The private key has no flag because passing it in argv would expose it. stdioCmd.Flags().String("app-id", "", "GitHub App ID or client ID, enabling non-interactive server-to-server authentication") stdioCmd.Flags().String("app-installation-id", "", "GitHub App installation ID to mint installation access tokens for") stdioCmd.Flags().String("app-private-key-path", "", "Path to the GitHub App private key (PEM). Preferred over GITHUB_APP_PRIVATE_KEY: keeps the key off the command line and out of the environment") @@ -326,19 +312,11 @@ func main() { } } -// buildAppAuthConfig assembles the GitHub App server-to-server configuration: -// it loads and parses the private key and resolves the REST base URL for the -// configured host. The private key is read from a file (preferred) or an inline -// environment value; a missing or partial configuration yields a clear error. -func buildAppAuthConfig(appID, installationID, keyPath, keyInline, host string) (*githubapp.Config, error) { +func newGitHubAppTokenProvider(appID, installationID, keyPath, keyInline, host string) (func() string, error) { keyBytes, err := loadAppPrivateKey(keyPath, keyInline) if err != nil { return nil, err } - privateKey, err := githubapp.ParsePrivateKey(keyBytes) - if err != nil { - return nil, fmt.Errorf("invalid GitHub App private key: %w", err) - } apiHost, err := utils.NewAPIHost(host) if err != nil { @@ -349,22 +327,18 @@ func buildAppAuthConfig(appID, installationID, keyPath, keyInline, host string) return nil, fmt.Errorf("failed to resolve REST URL for GitHub App authentication: %w", err) } - cfg := &githubapp.Config{ + provider, err := githubapp.NewProvider(githubapp.Config{ AppID: appID, InstallationID: installationID, - PrivateKey: privateKey, + PrivateKeyPEM: keyBytes, BaseRESTURL: restURL.String(), + }, nil) + if err != nil { + return nil, fmt.Errorf("failed to configure GitHub App authentication: %w", err) } - if err := cfg.Validate(); err != nil { - return nil, err - } - return cfg, nil + return provider.AccessToken, nil } -// loadAppPrivateKey returns the GitHub App private key bytes from a file path -// (preferred — it keeps the key off argv and out of the environment) or from an -// inline value. The inline form tolerates literal "\n" escapes so a PEM survives -// being carried in a single-line environment variable. func loadAppPrivateKey(path, inline string) ([]byte, error) { switch { case path != "": diff --git a/cmd/github-mcp-server/main_test.go b/cmd/github-mcp-server/main_test.go new file mode 100644 index 0000000000..476f308721 --- /dev/null +++ b/cmd/github-mcp-server/main_test.go @@ -0,0 +1,38 @@ +package main + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLoadAppPrivateKey(t *testing.T) { + t.Run("file", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "app.pem") + require.NoError(t, os.WriteFile(path, []byte("from-file"), 0o600)) + + key, err := loadAppPrivateKey(path, "from-inline") + require.NoError(t, err) + assert.Equal(t, []byte("from-file"), key) + }) + + t.Run("inline", func(t *testing.T) { + key, err := loadAppPrivateKey("", `first\nsecond`) + require.NoError(t, err) + assert.Equal(t, []byte("first\nsecond"), key) + }) + + t.Run("missing", func(t *testing.T) { + _, err := loadAppPrivateKey("", "") + require.Error(t, err) + assert.Contains(t, err.Error(), "private key") + }) +} + +func TestGitHubAppFlagsAreStdioOnly(t *testing.T) { + assert.NotNil(t, stdioCmd.Flags().Lookup("app-id")) + assert.Nil(t, httpCmd.Flags().Lookup("app-id")) +} diff --git a/docs/github-app-auth.md b/docs/github-app-auth.md index ed9a3db11f..f1da08c7bc 100644 --- a/docs/github-app-auth.md +++ b/docs/github-app-auth.md @@ -1,143 +1,34 @@ -# GitHub App Server-to-Server Authentication (stdio) +# GitHub App authentication -The local (stdio) GitHub MCP Server can authenticate as a **GitHub App -installation** instead of as a user. This is a **server-to-server** (s2s) flow: -the server signs a short-lived JSON Web Token (JWT) with your app's private key, -exchanges it for an installation access token, and refreshes that token -automatically. There is **no browser, no device code, and no elicitation**, so -it works in fully non-interactive environments — CI, Kubernetes, and background -agents such as Copilot's cloud agent. +The local stdio server can authenticate as a GitHub App installation without a +browser, device flow, or elicitation. It signs a short-lived JWT with the app's +private key, exchanges it for an installation access token, and refreshes the +token before it expires. -> [!WARNING] -> **Read this before you enable it.** This mode was added by popular demand, but -> it is **dangerous** and is **not recommended without an independent security -> review** of your deployment and of this implementation. -> -> - It places a **long-lived, high-privilege credential** (your app's private -> key) in the same environment as an AI agent. Anyone or anything that can read -> that environment can mint tokens that act as your app. -> - Installation access tokens minted here can act across **every repository the -> app is installed on**, with the app's full set of permissions. -> - Exposing credentials to agents — and **especially in the cloud** — is -> inherently risky. Treat this as a break-glass capability and proceed with -> **extreme caution**. -> -> If an interactive login is at all possible for your use case, prefer -> [OAuth login](oauth-login.md) instead, which keeps no long-lived secret next to -> the agent. - -## Contents - -- [When to use this](#when-to-use-this) -- [Why stdio only](#why-stdio-only) -- [How it works](#how-it-works) -- [Prerequisites](#prerequisites) -- [Configuration reference](#configuration-reference) -- [Injecting the private key safely](#injecting-the-private-key-safely) -- [Quick start](#quick-start) -- [Kubernetes](#kubernetes) -- [GitHub Enterprise Server and ghe.com](#github-enterprise-server-and-ghecom) -- [Reducing the blast radius](#reducing-the-blast-radius) -- [Troubleshooting](#troubleshooting) - -## When to use this - -Use GitHub App s2s auth only when **all** of the following hold: - -- The server runs **non-interactively** (no human to complete a browser or - device flow). -- The workload should act as an **organization-managed identity** (the app), - not a single user's Personal Access Token (PAT). -- You have reviewed the security implications above and accept them. - -For everything else, prefer [OAuth login](oauth-login.md) or a -[PAT](https://github.com/settings/personal-access-tokens/new). - -## Why stdio only - -This mode is deliberately limited to the **stdio** server, where the server runs -as a subprocess of a single trusted client and the minted token never crosses -that process boundary. - -It is intentionally **not** available for the `http` server. An HTTP server that -authenticated with a server-wide app identity would let **any** client that can -reach its endpoint act as the app, with the app's full permissions — turning a -network-reachable port into ambient, unauthenticated access to your whole -installation. The `http` server therefore keeps requiring a per-request -`Authorization` token, so every caller's identity and permissions stay explicit. +This authentication mode is not available for the `http` command. HTTP clients +must continue to provide their own `Authorization` token. -If you need a hosted, networked deployment, authenticate callers at the -client/proxy layer and pass per-request tokens; don't give the server a standing -identity. - -## How it works - -1. The server builds a JWT and signs it with your app's private key (RS256). The - JWT is valid for under 10 minutes (GitHub's maximum) and identifies your app. -2. It calls `POST /app/installations/{installation_id}/access_tokens` with that - JWT to obtain an **installation access token** (prefixed `ghs_`), which is - valid for up to one hour. -3. Every GitHub API call uses that token. The server refreshes it about five - minutes before it expires, so long-running sessions keep working without any - intervention. - -The private key is held **in memory only**; the server never writes it or the -minted tokens to disk. - -## Prerequisites - -1. **Register a GitHub App** and generate a **private key** (Settings → your - app → *Private keys* → *Generate a private key*). GitHub downloads a `.pem` - file in PKCS#1 or PKCS#8 format — both are accepted. -2. **Install the app** on the account/organization and grant it the **minimum** - permissions and **only the repositories** it needs (see - [Reducing the blast radius](#reducing-the-blast-radius)). -3. Note three values: - - the **App ID** (or the app's **client ID** — either works as the JWT issuer), - - the **installation ID** (visible in the installation's settings URL, or via - the [installations API](https://docs.github.com/en/rest/apps/apps#list-installations-for-the-authenticated-app)), - - the path to the **private key** `.pem`. +> [!WARNING] +> The private key can mint tokens for every repository and permission granted to +> the installation. Keep it out of source control, restrict access to the server +> process, and install the app only on the repositories it needs. -## Configuration reference +## Configuration -App auth is enabled when **any** of these `app-*` settings is present; a -partial configuration produces a clear startup error. Settings apply only to the -`stdio` command. +Configure exactly one of a Personal Access Token, OAuth login, or GitHub App +authentication. | Flag | Environment variable | Description | |------|----------------------|-------------| -| `--app-id` | `GITHUB_APP_ID` | GitHub App ID or client ID. Becomes the JWT issuer. | -| `--app-installation-id` | `GITHUB_APP_INSTALLATION_ID` | Installation ID whose token is minted. | -| `--app-private-key-path` | `GITHUB_APP_PRIVATE_KEY_PATH` | Path to the private key PEM file. **Preferred** way to supply the key. | -| _(no flag)_ | `GITHUB_APP_PRIVATE_KEY` | The PEM contents inline. Use only where a file can't be mounted. Literal `\n` sequences are accepted so the key can live in a single-line variable. | - -There is intentionally **no flag** for the private key contents: a flag would -place the key in the process's command line (`ps`, `/proc//cmdline`), where -other processes could read it. - -App auth is **mutually exclusive** with a PAT (`GITHUB_PERSONAL_ACCESS_TOKEN`) -and with OAuth login (`--oauth-client-id`). Configure exactly one. - -## Injecting the private key safely +| `--app-id` | `GITHUB_APP_ID` | App ID or client ID used as the JWT issuer | +| `--app-installation-id` | `GITHUB_APP_INSTALLATION_ID` | Installation whose access token is used | +| `--app-private-key-path` | `GITHUB_APP_PRIVATE_KEY_PATH` | Path to the private key PEM | +| _(none)_ | `GITHUB_APP_PRIVATE_KEY` | PEM contents, optionally with literal `\n` escapes | -The private key is the most sensitive value in this flow. In order of -preference: +A mounted private-key file is preferred. There is no flag for inline PEM +contents because command-line arguments may be visible to other processes. -1. **A mounted secret file** (recommended). Point `GITHUB_APP_PRIVATE_KEY_PATH` - at a file your platform mounts from its secret store — a Kubernetes secret - volume, a Docker secret, or a tmpfs file written by your secret manager. The - key never touches the command line or the process environment. -2. **An inline environment variable** (`GITHUB_APP_PRIVATE_KEY`). Acceptable - where files can't be mounted, but the key is then readable by anything that - can inspect the process environment. Avoid this in shared or cloud - environments. - -Never pass the key on the command line, never bake it into an image, and never -commit it to source control. - -## Quick start - -Native binary, key on disk: +## Usage ```bash github-mcp-server stdio \ @@ -146,7 +37,7 @@ github-mcp-server stdio \ --app-private-key-path /secrets/github-app.pem ``` -Equivalently, with environment variables: +The equivalent environment configuration is: ```bash export GITHUB_APP_ID=123456 @@ -155,7 +46,7 @@ export GITHUB_APP_PRIVATE_KEY_PATH=/secrets/github-app.pem github-mcp-server stdio ``` -Docker, mounting the key as a read-only file (preferred over passing it inline): +For Docker, mount the key read-only: ```bash docker run -i --rm \ @@ -166,96 +57,17 @@ docker run -i --rm \ ghcr.io/github/github-mcp-server ``` -## Kubernetes - -Store the key in a `Secret` and mount it as a file; pass the IDs as environment -variables. This keeps the key off the command line and out of the container's -environment. - -```yaml -apiVersion: v1 -kind: Secret -metadata: - name: github-app -type: Opaque -stringData: - private-key.pem: | - -----BEGIN RSA PRIVATE KEY----- - ... - -----END RSA PRIVATE KEY----- ---- -apiVersion: v1 -kind: Pod -metadata: - name: github-mcp-server -spec: - containers: - - name: github-mcp-server - image: ghcr.io/github/github-mcp-server - stdin: true - env: - - name: GITHUB_APP_ID - value: "123456" - - name: GITHUB_APP_INSTALLATION_ID - value: "7891011" - - name: GITHUB_APP_PRIVATE_KEY_PATH - value: /secrets/github-app/private-key.pem - volumeMounts: - - name: github-app - mountPath: /secrets/github-app - readOnly: true - volumes: - - name: github-app - secret: - secretName: github-app -``` - -## GitHub Enterprise Server and ghe.com - -Set the host with `--gh-host` / `GITHUB_HOST`; the server derives the correct -installation token endpoint from it, so tokens are minted against your instance -rather than github.com. Register the app and generate its key on that same host. - -```bash -github-mcp-server stdio \ - --gh-host https://github.example.com \ - --app-id 123456 \ - --app-installation-id 7891011 \ - --app-private-key-path /secrets/github-app.pem -``` - -- For GitHub Enterprise Server, prefix the host with `https://`. -- For `ghe.com`, use `https://YOURSUBDOMAIN.ghe.com`. - -## Reducing the blast radius - -Because the minted token can act across the whole installation, minimize what it -can do: - -- **Grant least privilege.** Enable only the app permissions the workload needs, - and prefer read-only where possible. -- **Scope the installation to specific repositories** rather than *All - repositories*. -- **Rotate the private key** periodically and immediately if it may have been - exposed (Settings → your app → *Private keys*). -- **Isolate the runtime.** Run the server where only trusted code shares its - process environment and mounted secrets. -- **Combine with `--read-only` and toolset/scoping flags** to further narrow - what the agent can invoke. See the - [Server Configuration Guide](server-configuration.md). +For GitHub Enterprise Server or `ghe.com`, also set `--gh-host` or +`GITHUB_HOST`. The server derives the installation-token endpoint from that +host. ## Troubleshooting -- **`GitHub App authentication requires a private key`** — you set some `app-*` - values but no key. Set `GITHUB_APP_PRIVATE_KEY_PATH` (preferred) or +- **Private key required**: set `GITHUB_APP_PRIVATE_KEY_PATH` or `GITHUB_APP_PRIVATE_KEY`. -- **`invalid GitHub App private key`** — the PEM could not be parsed. Ensure it - is the app's RSA private key in PKCS#1 or PKCS#8 form and was not truncated - (when inline, encode newlines as literal `\n`). -- **`installation token request failed: 401`** — usually a clock-skew problem or - the wrong App ID/key pairing. Check the host clock and that the key belongs to - the configured app. -- **`installation token request failed: 404`** — the installation ID is wrong, - or the app is not installed where you think. Re-check the installation ID. -- **`... and GITHUB_PERSONAL_ACCESS_TOKEN are mutually exclusive`** — a PAT is - also set in the environment. Unset it; choose exactly one auth mode. +- **Invalid private key**: provide the RSA PEM generated in the GitHub App + settings. PKCS#1 and PKCS#8 keys are supported. +- **401 from the installation-token endpoint**: verify the app ID or client ID, + private key, target host, and system clock. +- **404 from the installation-token endpoint**: verify the installation ID and + that the app is installed on the target host. diff --git a/docs/oauth-login.md b/docs/oauth-login.md index 31a0c90dce..92fc79c9df 100644 --- a/docs/oauth-login.md +++ b/docs/oauth-login.md @@ -15,12 +15,8 @@ pass `--oauth-client-id` (see [Bring your own app](#bring-your-own-app)). > `http` command have their own authentication; see > [Remote Server](remote-server.md). -> **Running non-interactively?** OAuth still needs a human to complete the flow -> once. For fully headless deployments (CI, Kubernetes, background agents), -> authenticate as a GitHub App installation instead — see -> [GitHub App Server-to-Server Authentication](github-app-auth.md). Note the -> security warnings there: it keeps a high-privilege credential next to the -> agent and is not recommended without an independent security review. +> For non-interactive stdio deployments, see +> [GitHub App authentication](github-app-auth.md). ## Contents diff --git a/internal/ghmcp/oauth_test.go b/internal/ghmcp/oauth_test.go index 46c62d1156..b358876232 100644 --- a/internal/ghmcp/oauth_test.go +++ b/internal/ghmcp/oauth_test.go @@ -9,7 +9,6 @@ import ( "net/http/httptest" "testing" - "github.com/github/github-mcp-server/internal/githubapp" "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" @@ -539,10 +538,6 @@ func TestOAuthMultiRoundTripResultType(t *testing.T) { assert.False(t, toolRan) } -// TestRunStdioServerRejectsMultipleAuthModes verifies the mutually-exclusive -// guard: supplying more than one of a static token, an OAuth manager, or GitHub -// App auth is rejected before the server starts, rather than silently preferring -// one for auth and another for scope filtering. func TestRunStdioServerRejectsMultipleAuthModes(t *testing.T) { t.Parallel() @@ -557,12 +552,12 @@ func TestRunStdioServerRejectsMultipleAuthModes(t *testing.T) { cfg: StdioServerConfig{Token: "ghp_static", OAuthManager: mgr}, }, { - name: "token and app", - cfg: StdioServerConfig{Token: "ghp_static", AppAuth: &githubapp.Config{}}, + name: "token and provider", + cfg: StdioServerConfig{Token: "ghp_static", TokenProvider: func() string { return "token" }}, }, { - name: "oauth and app", - cfg: StdioServerConfig{OAuthManager: mgr, AppAuth: &githubapp.Config{}}, + name: "oauth and provider", + cfg: StdioServerConfig{OAuthManager: mgr, TokenProvider: func() string { return "token" }}, }, } for _, tt := range tests { @@ -575,10 +570,8 @@ func TestRunStdioServerRejectsMultipleAuthModes(t *testing.T) { } } -// 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. +// TestCreateGitHubClientsTokenProvider verifies that clients resolve the +// provider for every request instead of pinning a token. func TestCreateGitHubClientsTokenProvider(t *testing.T) { t.Parallel() diff --git a/internal/ghmcp/server.go b/internal/ghmcp/server.go index 67c3b067e7..f13bdc476e 100644 --- a/internal/ghmcp/server.go +++ b/internal/ghmcp/server.go @@ -12,7 +12,6 @@ import ( "syscall" "time" - "github.com/github/github-mcp-server/internal/githubapp" "github.com/github/github-mcp-server/internal/oauth" "github.com/github/github-mcp-server/pkg/errors" "github.com/github/github-mcp-server/pkg/github" @@ -63,7 +62,7 @@ func createGitHubClients(cfg github.MCPServerConfig, apiHost utils.APIHostResolv return nil, fmt.Errorf("failed to get Raw URL: %w", err) } - // Construct REST client. When a TokenProvider is configured (OAuth), we + // Construct REST client. When a TokenProvider is configured, 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. @@ -259,30 +258,20 @@ type StdioServerConfig struct { // nothing; an explicit, narrower list filters accordingly. OAuthScopes []string - // AppAuth, when non-nil, enables non-interactive GitHub App server-to-server - // authentication: the server mints and transparently refreshes installation - // access tokens from the app's private key, with no browser, device code, or - // elicitation. It suits headless deployments (CI, Kubernetes, background - // agents). It is mutually exclusive with a static Token and with - // OAuthManager. See internal/githubapp and docs/github-app-auth.md — this - // injects a high-privilege credential alongside the agent and should not be - // used without an independent security review. - AppAuth *githubapp.Config + // TokenProvider supplies a token for each GitHub API request. + TokenProvider func() string } // RunStdioServer is not concurrent safe. func RunStdioServer(cfg StdioServerConfig) error { - // A static token, OAuth login, and GitHub App auth are mutually exclusive: - // they disagree on how the token is sourced (static vs. lazy provider) and - // on scope filtering, so reject any ambiguous combination up front. authModes := 0 - for _, on := range []bool{cfg.Token != "", cfg.OAuthManager != nil, cfg.AppAuth != nil} { + for _, on := range []bool{cfg.Token != "", cfg.OAuthManager != nil, cfg.TokenProvider != nil} { if on { authModes++ } } if authModes > 1 { - return fmt.Errorf("choose exactly one authentication mode: a static Token, OAuthManager (OAuth login), or AppAuth (GitHub App)") + return fmt.Errorf("choose exactly one authentication mode: a static Token, OAuthManager, or TokenProvider") } // Create app context @@ -307,20 +296,6 @@ func RunStdioServer(cfg StdioServerConfig) error { logger := slog.New(slogHandler) logger.Info("starting server", "version", cfg.Version, "host", cfg.Host, "readOnly", cfg.ReadOnly, "lockdownEnabled", cfg.LockdownMode) - // GitHub App server-to-server auth mints installation tokens with no human - // in the loop. Build the provider here so it can use the configured logger. - var appProvider *githubapp.Provider - if cfg.AppAuth != nil { - // Surfaced loudly because this injects a high-privilege credential next - // to the agent; the detailed guidance lives in docs/github-app-auth.md. - logger.Warn("GitHub App server-to-server authentication is enabled; installation tokens minted here can act across every repository the app is installed on — review docs/github-app-auth.md and prefer least-privilege, repository-scoped installations") - provider, err := githubapp.NewProvider(*cfg.AppAuth, logger) - if err != nil { - return fmt.Errorf("failed to configure GitHub App authentication: %w", err) - } - appProvider = provider - } - // 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 @@ -342,17 +317,11 @@ func RunStdioServer(cfg StdioServerConfig) error { logger.Debug("skipping scope filtering for non-PAT token") } - // For OAuth or GitHub App auth, the token is resolved lazily by a provider: - // empty until the user authorizes (OAuth) or minted on demand and refreshed - // (App). A static PAT, by contrast, is passed through unchanged. - var tokenProvider func() string + tokenProvider := cfg.TokenProvider var toolHandlerMiddleware []inventory.ToolHandlerMiddleware - switch { - case cfg.OAuthManager != nil: + if cfg.OAuthManager != nil { tokenProvider = cfg.OAuthManager.AccessToken toolHandlerMiddleware = append(toolHandlerMiddleware, createOAuthToolMiddleware(cfg.OAuthManager, logger)) - case appProvider != nil: - tokenProvider = appProvider.AccessToken } ghServer, err := NewStdioMCPServer(ctx, github.MCPServerConfig{ diff --git a/internal/githubapp/githubapp.go b/internal/githubapp/githubapp.go index 49072b93f0..bdd04af2cd 100644 --- a/internal/githubapp/githubapp.go +++ b/internal/githubapp/githubapp.go @@ -1,23 +1,4 @@ -// Package githubapp implements non-interactive GitHub App server-to-server -// (s2s) authentication for the stdio server. -// -// Unlike the user-to-server OAuth flows in internal/oauth, this requires no -// human: no browser, no device code, no elicitation. It signs a short-lived -// JWT with the app's private key, exchanges it for an installation access -// token, and transparently refreshes that token before it expires. That makes -// it suitable for headless deployments — CI, Kubernetes, background agents. -// -// It only depends on the standard library and golang.org/x/oauth2. -// -// # Security -// -// This mode injects a long-lived, high-privilege credential (the app private -// key) into an environment shared with an AI agent, and the installation -// tokens it mints can act across every repository the app is installed on. It -// was added by popular demand for non-interactive deployments, but exposing -// credentials to agents — especially in the cloud — is dangerous and is not -// recommended without an independent security review. See -// docs/github-app-auth.md for the full guidance and least-privilege advice. +// Package githubapp provides GitHub App installation access tokens. package githubapp import ( @@ -36,7 +17,6 @@ import ( "log/slog" "net/http" "net/url" - "os" "strings" "sync" "time" @@ -45,48 +25,35 @@ import ( ) const ( - // jwtLifetime is how long minted app JWTs are valid. GitHub rejects app JWTs - // whose exp is more than 10 minutes in the future; 9 minutes leaves headroom. - jwtLifetime = 9 * time.Minute - - // clockSkew backdates the JWT iat to tolerate small clock differences - // between this host and GitHub, which would otherwise reject the JWT. - clockSkew = 60 * time.Second - - // refreshBuffer refreshes installation tokens this long before their real - // expiry so an in-flight request never races the expiry boundary. + jwtLifetime = 9 * time.Minute + clockSkew = time.Minute refreshBuffer = 5 * time.Minute - - // httpTimeout bounds each call to the installation token endpoint so a - // stalled GitHub API cannot block a tool call indefinitely. - httpTimeout = 30 * time.Second + httpTimeout = 30 * time.Second ) // Config describes a GitHub App installation used for server-to-server auth. type Config struct { - // AppID is the GitHub App's App ID or client ID; it becomes the JWT issuer - // (iss). Both forms are accepted by GitHub. + // AppID is used as the JWT issuer. GitHub accepts an app ID or client ID. AppID string // InstallationID identifies the installation whose access token is minted. InstallationID string - // PrivateKey signs the app JWT (RS256). Parse one with ParsePrivateKey. - PrivateKey *rsa.PrivateKey + // PrivateKeyPEM is the RSA key used to sign app JWTs. + PrivateKeyPEM []byte // BaseRESTURL is the REST API base, e.g. https://api.github.com/ for // github.com or https://HOST/api/v3/ for GitHub Enterprise Server. BaseRESTURL string } -// Validate reports whether the configuration is complete enough to mint tokens. -func (c Config) Validate() error { +func (c Config) validate() error { switch { case c.AppID == "": - return errors.New("GitHub App ID is required (GITHUB_APP_ID)") + return errors.New("GitHub App ID or client ID is required (GITHUB_APP_ID)") case c.InstallationID == "": return errors.New("GitHub App installation ID is required (GITHUB_APP_INSTALLATION_ID)") - case c.PrivateKey == nil: + case len(c.PrivateKeyPEM) == 0: return errors.New("GitHub App private key is required (GITHUB_APP_PRIVATE_KEY_PATH or GITHUB_APP_PRIVATE_KEY)") case c.BaseRESTURL == "": return errors.New("GitHub App REST base URL is required") @@ -94,10 +61,7 @@ func (c Config) Validate() error { return nil } -// ParsePrivateKey parses a PEM-encoded RSA private key in PKCS#1 ("RSA PRIVATE -// KEY") or PKCS#8 ("PRIVATE KEY") form — the two formats GitHub issues for app -// keys. -func ParsePrivateKey(pemBytes []byte) (*rsa.PrivateKey, error) { +func parsePrivateKey(pemBytes []byte) (*rsa.PrivateKey, error) { block, _ := pem.Decode(pemBytes) if block == nil { return nil, errors.New("no PEM block found in private key") @@ -116,14 +80,12 @@ func ParsePrivateKey(pemBytes []byte) (*rsa.PrivateKey, error) { return key, nil } -// mintJWT builds and signs a short-lived app JWT (RS256) for the configured -// app, as required by the installation token endpoint. -func (c Config) mintJWT(now time.Time) (string, error) { +func mintJWT(appID string, privateKey *rsa.PrivateKey, now time.Time) (string, error) { header := map[string]string{"alg": "RS256", "typ": "JWT"} claims := map[string]any{ "iat": now.Add(-clockSkew).Unix(), "exp": now.Add(jwtLifetime).Unix(), - "iss": c.AppID, + "iss": appID, } headerJSON, err := json.Marshal(header) @@ -139,7 +101,7 @@ func (c Config) mintJWT(now time.Time) (string, error) { base64.RawURLEncoding.EncodeToString(claimsJSON) digest := sha256.Sum256([]byte(signingInput)) - signature, err := rsa.SignPKCS1v15(rand.Reader, c.PrivateKey, crypto.SHA256, digest[:]) + signature, err := rsa.SignPKCS1v15(rand.Reader, privateKey, crypto.SHA256, digest[:]) if err != nil { return "", fmt.Errorf("signing JWT: %w", err) } @@ -147,25 +109,21 @@ func (c Config) mintJWT(now time.Time) (string, error) { return signingInput + "." + base64.RawURLEncoding.EncodeToString(signature), nil } -// installationTokenSource is an oauth2.TokenSource that mints GitHub App -// installation access tokens. It performs no caching itself; wrap it in -// oauth2.ReuseTokenSource (see NewProvider) for that. type installationTokenSource struct { cfg Config + privateKey *rsa.PrivateKey httpClient *http.Client } -func newInstallationTokenSource(cfg Config, httpClient *http.Client) *installationTokenSource { +func newInstallationTokenSource(cfg Config, privateKey *rsa.PrivateKey, httpClient *http.Client) *installationTokenSource { if httpClient == nil { httpClient = &http.Client{Timeout: httpTimeout} } - return &installationTokenSource{cfg: cfg, httpClient: httpClient} + return &installationTokenSource{cfg: cfg, privateKey: privateKey, httpClient: httpClient} } -// Token mints a fresh installation access token. The returned token's Expiry is -// set refreshBuffer before the real expiry so callers refresh early. func (s *installationTokenSource) Token() (*oauth2.Token, error) { - jwt, err := s.cfg.mintJWT(time.Now()) + jwt, err := mintJWT(s.cfg.AppID, s.privateKey, time.Now()) if err != nil { return nil, err } @@ -193,9 +151,10 @@ func (s *installationTokenSource) Token() (*oauth2.Token, error) { defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusCreated { - // The error body is GitHub's JSON message (never the token); include a - // bounded snippet to make misconfiguration diagnosable. - snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + snippet, readErr := io.ReadAll(io.LimitReader(resp.Body, 512)) + if readErr != nil { + return nil, fmt.Errorf("installation token request failed: %s (reading response: %w)", resp.Status, readErr) + } return nil, fmt.Errorf("installation token request failed: %s: %s", resp.Status, strings.TrimSpace(string(snippet))) } @@ -209,21 +168,17 @@ func (s *installationTokenSource) Token() (*oauth2.Token, error) { if body.Token == "" { return nil, errors.New("installation token response did not contain a token") } - - expiry := body.ExpiresAt - if !expiry.IsZero() { - expiry = expiry.Add(-refreshBuffer) + if body.ExpiresAt.IsZero() { + return nil, errors.New("installation token response did not contain an expiry") } return &oauth2.Token{ AccessToken: body.Token, TokenType: "token", - Expiry: expiry, + Expiry: body.ExpiresAt.Add(-refreshBuffer), }, nil } -// Provider supplies GitHub App installation access tokens, caching and -// refreshing them transparently. Its AccessToken method mirrors -// oauth.Manager.AccessToken so it can back BearerAuthTransport.TokenProvider. +// Provider caches and refreshes GitHub App installation access tokens. type Provider struct { source oauth2.TokenSource logger *slog.Logger @@ -232,26 +187,22 @@ type Provider struct { errLogged bool } -// NewProvider validates cfg and returns a Provider that mints and refreshes -// installation tokens. A nil logger logs to stderr. func NewProvider(cfg Config, logger *slog.Logger) (*Provider, error) { - if err := cfg.Validate(); err != nil { + if err := cfg.validate(); err != nil { return nil, err } + privateKey, err := parsePrivateKey(cfg.PrivateKeyPEM) + if err != nil { + return nil, fmt.Errorf("invalid GitHub App private key: %w", err) + } if logger == nil { - logger = slog.New(slog.NewTextHandler(os.Stderr, nil)) + logger = slog.Default() } - // ReuseTokenSource caches the token and only calls the underlying source - // once the cached token is expired. Because Token() backdates Expiry by - // refreshBuffer, that refresh happens ~5 minutes before the real expiry. - source := oauth2.ReuseTokenSource(nil, newInstallationTokenSource(cfg, nil)) + source := oauth2.ReuseTokenSource(nil, newInstallationTokenSource(cfg, privateKey, nil)) return &Provider{source: source, logger: logger}, nil } -// AccessToken returns a currently valid installation access token, refreshing -// it if needed, or "" if a token could not be obtained. A fetch failure is -// logged once (until the next success) so a misconfiguration is visible without -// flooding the log on every tool call. +// AccessToken returns a cached token or refreshes it before expiry. func (p *Provider) AccessToken() string { tok, err := p.source.Token() if err != nil { @@ -268,8 +219,3 @@ func (p *Provider) AccessToken() string { p.mu.Unlock() return tok.AccessToken } - -// HasToken reports whether a valid token can currently be obtained. -func (p *Provider) HasToken() bool { - return p.AccessToken() != "" -} diff --git a/internal/githubapp/githubapp_test.go b/internal/githubapp/githubapp_test.go index c2d5eeff7f..6828dbc2dc 100644 --- a/internal/githubapp/githubapp_test.go +++ b/internal/githubapp/githubapp_test.go @@ -33,7 +33,7 @@ func newTestKey(t *testing.T) *rsa.PrivateKey { func pkcs1PEM(t *testing.T, key *rsa.PrivateKey) []byte { t.Helper() - return pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}) + return pkcs1PEMBytes(key) } func pkcs8PEM(t *testing.T, key *rsa.PrivateKey) []byte { @@ -47,19 +47,19 @@ func TestParsePrivateKey(t *testing.T) { key := newTestKey(t) t.Run("PKCS1", func(t *testing.T) { - got, err := ParsePrivateKey(pkcs1PEM(t, key)) + got, err := parsePrivateKey(pkcs1PEM(t, key)) require.NoError(t, err) assert.Equal(t, key.N, got.N) }) t.Run("PKCS8", func(t *testing.T) { - got, err := ParsePrivateKey(pkcs8PEM(t, key)) + got, err := parsePrivateKey(pkcs8PEM(t, key)) require.NoError(t, err) assert.Equal(t, key.N, got.N) }) t.Run("not PEM", func(t *testing.T) { - _, err := ParsePrivateKey([]byte("not a pem")) + _, err := parsePrivateKey([]byte("not a pem")) require.Error(t, err) assert.Contains(t, err.Error(), "no PEM block") }) @@ -71,7 +71,7 @@ func TestParsePrivateKey(t *testing.T) { require.NoError(t, err) keyPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}) - _, err = ParsePrivateKey(keyPEM) + _, err = parsePrivateKey(keyPEM) require.Error(t, err) assert.Contains(t, err.Error(), "want an RSA key") }) @@ -79,24 +79,24 @@ func TestParsePrivateKey(t *testing.T) { func TestConfigValidate(t *testing.T) { key := newTestKey(t) - base := Config{AppID: "123", InstallationID: "456", PrivateKey: key, BaseRESTURL: "https://api.github.com/"} - require.NoError(t, base.Validate()) + base := Config{AppID: "123", InstallationID: "456", PrivateKeyPEM: pkcs1PEM(t, key), BaseRESTURL: "https://api.github.com/"} + require.NoError(t, base.validate()) tests := []struct { name string mutate func(c *Config) want string }{ - {"missing app id", func(c *Config) { c.AppID = "" }, "App ID is required"}, + {"missing app id", func(c *Config) { c.AppID = "" }, "App ID or client ID is required"}, {"missing installation id", func(c *Config) { c.InstallationID = "" }, "installation ID is required"}, - {"missing private key", func(c *Config) { c.PrivateKey = nil }, "private key is required"}, + {"missing private key", func(c *Config) { c.PrivateKeyPEM = nil }, "private key is required"}, {"missing base url", func(c *Config) { c.BaseRESTURL = "" }, "REST base URL is required"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { c := base tt.mutate(&c) - err := c.Validate() + err := c.validate() require.Error(t, err) assert.Contains(t, err.Error(), tt.want) }) @@ -132,10 +132,8 @@ func verifyJWT(t *testing.T, token string, pub *rsa.PublicKey) map[string]any { func TestMintJWT(t *testing.T) { key := newTestKey(t) - cfg := Config{AppID: "my-app-id", PrivateKey: key} - now := time.Now() - token, err := cfg.mintJWT(now) + token, err := mintJWT("my-app-id", key, now) require.NoError(t, err) claims := verifyJWT(t, token, &key.PublicKey) @@ -173,7 +171,18 @@ func installationServer(t *testing.T, pub *rsa.PublicKey, token string, expiresA } func newTestConfig(key *rsa.PrivateKey, baseURL string) Config { - return Config{AppID: "123", InstallationID: "456", PrivateKey: key, BaseRESTURL: baseURL + "/"} + return Config{AppID: "123", InstallationID: "456", PrivateKeyPEM: pkcs1PEMBytes(key), BaseRESTURL: baseURL + "/"} +} + +func pkcs1PEMBytes(key *rsa.PrivateKey) []byte { + return pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}) +} + +func newTestTokenSource(t *testing.T, cfg Config, client *http.Client) *installationTokenSource { + t.Helper() + privateKey, err := parsePrivateKey(cfg.PrivateKeyPEM) + require.NoError(t, err) + return newInstallationTokenSource(cfg, privateKey, client) } func TestProviderFetchesToken(t *testing.T) { @@ -184,7 +193,6 @@ func TestProviderFetchesToken(t *testing.T) { require.NoError(t, err) assert.Equal(t, "ghs_fresh", provider.AccessToken()) - assert.True(t, provider.HasToken()) assert.Equal(t, int32(1), calls.Load()) } @@ -242,7 +250,7 @@ func TestProviderErrorIncludesStatus(t *testing.T) { })) t.Cleanup(srv.Close) - source := newInstallationTokenSource(newTestConfig(key, srv.URL), srv.Client()) + source := newTestTokenSource(t, newTestConfig(key, srv.URL), srv.Client()) _, err := source.Token() require.Error(t, err) assert.Contains(t, err.Error(), "404") @@ -252,20 +260,31 @@ func TestProviderErrorIncludesStatus(t *testing.T) { func TestNewProviderValidates(t *testing.T) { _, err := NewProvider(Config{}, nil) require.Error(t, err) - assert.Contains(t, err.Error(), "App ID is required") + assert.Contains(t, err.Error(), "App ID or client ID is required") } -// Ensure the source returns an error rather than panicking on a token-less 201. -func TestSourceRejectsEmptyToken(t *testing.T) { +func TestSourceRejectsIncompleteTokenResponse(t *testing.T) { key := newTestKey(t) - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusCreated) - _, _ = fmt.Fprint(w, `{"expires_at":"2099-01-01T00:00:00Z"}`) - })) - t.Cleanup(srv.Close) - - source := newInstallationTokenSource(newTestConfig(key, srv.URL), srv.Client()) - _, err := source.Token() - require.Error(t, err) - assert.Contains(t, err.Error(), "did not contain a token") + tests := []struct { + name string + body string + want string + }{ + {name: "missing token", body: `{"expires_at":"2099-01-01T00:00:00Z"}`, want: "did not contain a token"}, + {name: "missing expiry", body: `{"token":"ghs_token"}`, want: "did not contain an expiry"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusCreated) + _, _ = fmt.Fprint(w, tt.body) + })) + t.Cleanup(srv.Close) + + source := newTestTokenSource(t, newTestConfig(key, srv.URL), srv.Client()) + _, err := source.Token() + require.Error(t, err) + assert.Contains(t, err.Error(), tt.want) + }) + } } diff --git a/pkg/github/server.go b/pkg/github/server.go index 67db83a77a..43e0940017 100644 --- a/pkg/github/server.go +++ b/pkg/github/server.go @@ -69,8 +69,7 @@ type MCPServerConfig struct { TokenScopes []string // 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. + // request instead of the static Token. TokenProvider func() string // ToolHandlerMiddleware wraps every registered tool handler. Unlike MCP diff --git a/pkg/http/transport/bearer.go b/pkg/http/transport/bearer.go index 0c12ddfc91..6f2ae7fc98 100644 --- a/pkg/http/transport/bearer.go +++ b/pkg/http/transport/bearer.go @@ -13,9 +13,7 @@ type BearerAuthTransport struct { 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. + // and takes precedence over Token. TokenProvider func() string } @@ -25,8 +23,6 @@ func (t *BearerAuthTransport) RoundTrip(req *http.Request) (*http.Response, erro 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) } From c5f4caaec43950ffc29940e1634673b66440ec1a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 20 Jul 2026 16:24:14 +0000 Subject: [PATCH 29/35] build(deps): bump actions/setup-go from 6 to 7 Bumps [actions/setup-go](https://github.com/actions/setup-go) from 6 to 7. - [Release notes](https://github.com/actions/setup-go/releases) - [Commits](https://github.com/actions/setup-go/compare/v6...v7) --- updated-dependencies: - dependency-name: actions/setup-go dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/code-scanning.yml | 2 +- .github/workflows/docs-check.yml | 2 +- .github/workflows/go.yml | 2 +- .github/workflows/goreleaser.yml | 2 +- .github/workflows/license-check.yml | 2 +- .github/workflows/lint.yml | 2 +- .github/workflows/mcp-diff.yml | 4 ++-- .github/workflows/registry-releaser.yml | 2 +- 8 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/code-scanning.yml b/.github/workflows/code-scanning.yml index 3abb3f8fd5..7e62c07484 100644 --- a/.github/workflows/code-scanning.yml +++ b/.github/workflows/code-scanning.yml @@ -72,7 +72,7 @@ jobs: with: language: ${{ matrix.language }} - name: Setup Go - uses: actions/setup-go@v6 + uses: actions/setup-go@v7 if: matrix.language == 'go' && fromJSON(steps.resolve-environment.outputs.environment).configuration.go.version with: go-version: ${{ fromJSON(steps.resolve-environment.outputs.environment).configuration.go.version }} diff --git a/.github/workflows/docs-check.yml b/.github/workflows/docs-check.yml index 99fe8a8248..0514a1afe4 100644 --- a/.github/workflows/docs-check.yml +++ b/.github/workflows/docs-check.yml @@ -20,7 +20,7 @@ jobs: uses: ./.github/actions/build-ui - name: Set up Go - uses: actions/setup-go@v6 + uses: actions/setup-go@v7 with: go-version-file: 'go.mod' diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 8a2045045b..165c8e3815 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -29,7 +29,7 @@ jobs: uses: ./.github/actions/build-ui - name: Set up Go - uses: actions/setup-go@v6 + uses: actions/setup-go@v7 with: go-version-file: "go.mod" diff --git a/.github/workflows/goreleaser.yml b/.github/workflows/goreleaser.yml index aa98b8db8f..12e680a049 100644 --- a/.github/workflows/goreleaser.yml +++ b/.github/workflows/goreleaser.yml @@ -20,7 +20,7 @@ jobs: uses: ./.github/actions/build-ui - name: Set up Go - uses: actions/setup-go@v6 + uses: actions/setup-go@v7 with: go-version-file: "go.mod" diff --git a/.github/workflows/license-check.yml b/.github/workflows/license-check.yml index 9cd810bd75..80016c4a37 100644 --- a/.github/workflows/license-check.yml +++ b/.github/workflows/license-check.yml @@ -36,7 +36,7 @@ jobs: uses: ./.github/actions/build-ui - name: Set up Go - uses: actions/setup-go@v6 + uses: actions/setup-go@v7 with: go-version-file: "go.mod" diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 26734eaa28..6119ee9f0f 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -16,7 +16,7 @@ jobs: - uses: actions/checkout@v7 - name: Build UI uses: ./.github/actions/build-ui - - uses: actions/setup-go@v6 + - uses: actions/setup-go@v7 with: go-version: '1.25' - name: golangci-lint diff --git a/.github/workflows/mcp-diff.yml b/.github/workflows/mcp-diff.yml index 3c38e49020..653d71093a 100644 --- a/.github/workflows/mcp-diff.yml +++ b/.github/workflows/mcp-diff.yml @@ -20,7 +20,7 @@ jobs: fetch-depth: 0 - name: Set up Go - uses: actions/setup-go@v6 + uses: actions/setup-go@v7 with: go-version-file: go.mod @@ -90,7 +90,7 @@ jobs: fetch-depth: 0 - name: Set up Go - uses: actions/setup-go@v6 + uses: actions/setup-go@v7 with: go-version-file: go.mod diff --git a/.github/workflows/registry-releaser.yml b/.github/workflows/registry-releaser.yml index 033ba35c3c..7ab683f721 100644 --- a/.github/workflows/registry-releaser.yml +++ b/.github/workflows/registry-releaser.yml @@ -17,7 +17,7 @@ jobs: uses: actions/checkout@v7 - name: Setup Go - uses: actions/setup-go@v6 + uses: actions/setup-go@v7 with: go-version: "stable" From 5d13598c88b2e49fbf46e3aa046456f2aa7d84c2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 20 Jul 2026 16:24:20 +0000 Subject: [PATCH 30/35] build(deps): bump actions/setup-node from 6 to 7 Bumps [actions/setup-node](https://github.com/actions/setup-node) from 6 to 7. - [Release notes](https://github.com/actions/setup-node/releases) - [Commits](https://github.com/actions/setup-node/compare/v6...v7) --- updated-dependencies: - dependency-name: actions/setup-node dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/code-scanning.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/code-scanning.yml b/.github/workflows/code-scanning.yml index 7e62c07484..26459e71cd 100644 --- a/.github/workflows/code-scanning.yml +++ b/.github/workflows/code-scanning.yml @@ -80,7 +80,7 @@ jobs: - name: Set up Node.js (for JavaScript CodeQL) if: matrix.language == 'javascript' - uses: actions/setup-node@v6 + uses: actions/setup-node@v7 with: node-version: "20" cache: "npm" From 4f26c17aae0221428122a5452c3d6b4f964cb64f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:06:05 +0000 Subject: [PATCH 31/35] build(deps): bump golang.org/x/oauth2 from 0.35.0 to 0.36.0 Bumps [golang.org/x/oauth2](https://github.com/golang/oauth2) from 0.35.0 to 0.36.0. - [Commits](https://github.com/golang/oauth2/compare/v0.35.0...v0.36.0) --- updated-dependencies: - dependency-name: golang.org/x/oauth2 dependency-version: 0.36.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index aa12211489..1d8801f5a0 100644 --- a/go.mod +++ b/go.mod @@ -19,7 +19,7 @@ require ( github.com/spf13/viper v1.21.0 github.com/stretchr/testify v1.11.1 github.com/yosida95/uritemplate/v3 v3.0.2 - golang.org/x/oauth2 v0.35.0 + golang.org/x/oauth2 v0.36.0 ) require ( diff --git a/go.sum b/go.sum index 76e5a771ec..f6a655d510 100644 --- a/go.sum +++ b/go.sum @@ -94,8 +94,8 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= -golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= -golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= From b8bfb499e2f75406f3b84c9595ee99374b74dac2 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 16 Jul 2026 12:07:22 +0000 Subject: [PATCH 32/35] chore: regenerate license files Auto-generated by license-check workflow --- third-party-licenses.darwin.md | 2 +- third-party-licenses.linux.md | 2 +- third-party-licenses.windows.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/third-party-licenses.darwin.md b/third-party-licenses.darwin.md index 3e3be90704..2bf5e86eae 100644 --- a/third-party-licenses.darwin.md +++ b/third-party-licenses.darwin.md @@ -43,7 +43,7 @@ The following packages are included for the amd64, arm64 architectures. - [github.com/yosida95/uritemplate/v3](https://pkg.go.dev/github.com/yosida95/uritemplate/v3) ([BSD-3-Clause](https://github.com/yosida95/uritemplate/blob/v3.0.2/LICENSE)) - [go.yaml.in/yaml/v3](https://pkg.go.dev/go.yaml.in/yaml/v3) ([MIT](https://github.com/yaml/go-yaml/blob/v3.0.4/LICENSE)) - [golang.org/x/net/html](https://pkg.go.dev/golang.org/x/net/html) ([BSD-3-Clause](https://cs.opensource.google/go/x/net/+/v0.55.0:LICENSE)) - - [golang.org/x/oauth2](https://pkg.go.dev/golang.org/x/oauth2) ([BSD-3-Clause](https://cs.opensource.google/go/x/oauth2/+/v0.35.0:LICENSE)) + - [golang.org/x/oauth2](https://pkg.go.dev/golang.org/x/oauth2) ([BSD-3-Clause](https://cs.opensource.google/go/x/oauth2/+/v0.36.0:LICENSE)) - [golang.org/x/sync/errgroup](https://pkg.go.dev/golang.org/x/sync/errgroup) ([BSD-3-Clause](https://cs.opensource.google/go/x/sync/+/v0.20.0:LICENSE)) - [golang.org/x/sys](https://pkg.go.dev/golang.org/x/sys) ([BSD-3-Clause](https://cs.opensource.google/go/x/sys/+/v0.45.0:LICENSE)) - [golang.org/x/text](https://pkg.go.dev/golang.org/x/text) ([BSD-3-Clause](https://cs.opensource.google/go/x/text/+/v0.37.0:LICENSE)) diff --git a/third-party-licenses.linux.md b/third-party-licenses.linux.md index e686da2d83..4caa5f58b2 100644 --- a/third-party-licenses.linux.md +++ b/third-party-licenses.linux.md @@ -43,7 +43,7 @@ The following packages are included for the 386, amd64, arm64 architectures. - [github.com/yosida95/uritemplate/v3](https://pkg.go.dev/github.com/yosida95/uritemplate/v3) ([BSD-3-Clause](https://github.com/yosida95/uritemplate/blob/v3.0.2/LICENSE)) - [go.yaml.in/yaml/v3](https://pkg.go.dev/go.yaml.in/yaml/v3) ([MIT](https://github.com/yaml/go-yaml/blob/v3.0.4/LICENSE)) - [golang.org/x/net/html](https://pkg.go.dev/golang.org/x/net/html) ([BSD-3-Clause](https://cs.opensource.google/go/x/net/+/v0.55.0:LICENSE)) - - [golang.org/x/oauth2](https://pkg.go.dev/golang.org/x/oauth2) ([BSD-3-Clause](https://cs.opensource.google/go/x/oauth2/+/v0.35.0:LICENSE)) + - [golang.org/x/oauth2](https://pkg.go.dev/golang.org/x/oauth2) ([BSD-3-Clause](https://cs.opensource.google/go/x/oauth2/+/v0.36.0:LICENSE)) - [golang.org/x/sync/errgroup](https://pkg.go.dev/golang.org/x/sync/errgroup) ([BSD-3-Clause](https://cs.opensource.google/go/x/sync/+/v0.20.0:LICENSE)) - [golang.org/x/sys](https://pkg.go.dev/golang.org/x/sys) ([BSD-3-Clause](https://cs.opensource.google/go/x/sys/+/v0.45.0:LICENSE)) - [golang.org/x/text](https://pkg.go.dev/golang.org/x/text) ([BSD-3-Clause](https://cs.opensource.google/go/x/text/+/v0.37.0:LICENSE)) diff --git a/third-party-licenses.windows.md b/third-party-licenses.windows.md index fc15be2de6..a7164a2aad 100644 --- a/third-party-licenses.windows.md +++ b/third-party-licenses.windows.md @@ -44,7 +44,7 @@ The following packages are included for the 386, amd64, arm64 architectures. - [github.com/yosida95/uritemplate/v3](https://pkg.go.dev/github.com/yosida95/uritemplate/v3) ([BSD-3-Clause](https://github.com/yosida95/uritemplate/blob/v3.0.2/LICENSE)) - [go.yaml.in/yaml/v3](https://pkg.go.dev/go.yaml.in/yaml/v3) ([MIT](https://github.com/yaml/go-yaml/blob/v3.0.4/LICENSE)) - [golang.org/x/net/html](https://pkg.go.dev/golang.org/x/net/html) ([BSD-3-Clause](https://cs.opensource.google/go/x/net/+/v0.55.0:LICENSE)) - - [golang.org/x/oauth2](https://pkg.go.dev/golang.org/x/oauth2) ([BSD-3-Clause](https://cs.opensource.google/go/x/oauth2/+/v0.35.0:LICENSE)) + - [golang.org/x/oauth2](https://pkg.go.dev/golang.org/x/oauth2) ([BSD-3-Clause](https://cs.opensource.google/go/x/oauth2/+/v0.36.0:LICENSE)) - [golang.org/x/sync/errgroup](https://pkg.go.dev/golang.org/x/sync/errgroup) ([BSD-3-Clause](https://cs.opensource.google/go/x/sync/+/v0.20.0:LICENSE)) - [golang.org/x/sys](https://pkg.go.dev/golang.org/x/sys) ([BSD-3-Clause](https://cs.opensource.google/go/x/sys/+/v0.45.0:LICENSE)) - [golang.org/x/text](https://pkg.go.dev/golang.org/x/text) ([BSD-3-Clause](https://cs.opensource.google/go/x/text/+/v0.37.0:LICENSE)) From a217a7f43a76a4cd6909ba6814d88a886064488c Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Tue, 21 Jul 2026 11:00:36 -0700 Subject: [PATCH 33/35] Add MCP App form deferral opt-out Allow clients to keep MCP App views enabled while making form-backed write tools execute directly when explicitly configured. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/server-configuration.md | 11 ++++++++ pkg/github/feature_flags.go | 5 ++++ pkg/github/feature_flags_test.go | 11 ++++++++ pkg/github/ui_capability.go | 12 +++++---- pkg/github/ui_capability_test.go | 45 ++++++++++++++++++++++++++++++++ pkg/http/server_test.go | 12 +++++++++ 6 files changed, 91 insertions(+), 5 deletions(-) diff --git a/docs/server-configuration.md b/docs/server-configuration.md index 2342664c3a..500c4bb868 100644 --- a/docs/server-configuration.md +++ b/docs/server-configuration.md @@ -396,6 +396,17 @@ See [Insiders Features](./insiders-features.md) for a full list of what's availa MCP Apps is enabled by [Insiders Mode](#insiders-mode), or independently via the `remote_mcp_ui_apps` feature flag. +To keep MCP App result views enabled while making write tools execute directly +instead of first opening an interactive form, also enable the +`mcp_apps_disable_form_deferral` feature flag. For the remote server, send both +flags in the request header: + +```http +X-MCP-Features: remote_mcp_ui_apps,mcp_apps_disable_form_deferral +``` + +For the local server, pass both flags to `--features`. + **Supported tools:** | Tool | Description | diff --git a/pkg/github/feature_flags.go b/pkg/github/feature_flags.go index 442f427cb8..b0652c3346 100644 --- a/pkg/github/feature_flags.go +++ b/pkg/github/feature_flags.go @@ -5,6 +5,10 @@ import "slices" // MCPAppsFeatureFlag is the feature flag name for MCP Apps (interactive UI forms). const MCPAppsFeatureFlag = "remote_mcp_ui_apps" +// MCPAppsDisableFormDeferralFeatureFlag disables handing write-tool calls off +// to MCP App forms while preserving MCP Apps UI metadata and result views. +const MCPAppsDisableFormDeferralFeatureFlag = "mcp_apps_disable_form_deferral" + // FeatureFlagCSVOutput is the feature flag name for CSV output on list tools. const FeatureFlagCSVOutput = "csv_output" @@ -37,6 +41,7 @@ const FeatureFlagFieldsParam = "fields_param" // This is the single source of truth for which flags are user-controllable. var AllowedFeatureFlags = []string{ MCPAppsFeatureFlag, + MCPAppsDisableFormDeferralFeatureFlag, FeatureFlagCSVOutput, FeatureFlagIFCLabels, FeatureFlagIssuesGranular, diff --git a/pkg/github/feature_flags_test.go b/pkg/github/feature_flags_test.go index aa4a6c9d28..30f2b56122 100644 --- a/pkg/github/feature_flags_test.go +++ b/pkg/github/feature_flags_test.go @@ -155,6 +155,11 @@ func TestResolveFeatureFlags(t *testing.T) { enabledFeatures: []string{MCPAppsFeatureFlag}, expectedFlags: []string{MCPAppsFeatureFlag}, }, + { + name: "MCP Apps form deferral can be disabled directly", + enabledFeatures: []string{MCPAppsDisableFormDeferralFeatureFlag}, + expectedFlags: []string{MCPAppsDisableFormDeferralFeatureFlag}, + }, { name: "fields param is not enabled by default", enabledFeatures: nil, @@ -183,6 +188,12 @@ func TestResolveFeatureFlags(t *testing.T) { insidersMode: true, unexpectedFlags: []string{FeatureFlagIFCLabels}, }, + { + name: "insiders mode does not disable MCP Apps form deferral", + enabledFeatures: nil, + insidersMode: true, + unexpectedFlags: []string{MCPAppsDisableFormDeferralFeatureFlag}, + }, { name: "ifc_labels can be directly enabled", enabledFeatures: []string{FeatureFlagIFCLabels}, diff --git a/pkg/github/ui_capability.go b/pkg/github/ui_capability.go index a850db0c95..3de6a39b74 100644 --- a/pkg/github/ui_capability.go +++ b/pkg/github/ui_capability.go @@ -63,13 +63,15 @@ func hasNonFormParams(args map[string]any, formParams map[string]struct{}) bool // 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. +// are enabled, form deferral has not been disabled, 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) && + !deps.IsFeatureEnabled(ctx, MCPAppsDisableFormDeferralFeatureFlag) && clientSupportsUI(ctx, req) && !uiSubmitted(args) && !hasNonFormParams(args, formParams) diff --git a/pkg/github/ui_capability_test.go b/pkg/github/ui_capability_test.go index 72275d7c46..1c49ee15be 100644 --- a/pkg/github/ui_capability_test.go +++ b/pkg/github/ui_capability_test.go @@ -85,3 +85,48 @@ func Test_clientSupportsUI_fromContext(t *testing.T) { assert.False(t, clientSupportsUI(context.Background(), nil)) }) } + +func Test_shouldDeferToForm_featureFlags(t *testing.T) { + t.Parallel() + + ctx := ghcontext.WithUISupport(context.Background(), true) + args := map[string]any{"owner": "octocat"} + formParams := map[string]struct{}{"owner": {}} + + tests := []struct { + name string + enabledFlags []string + want bool + }{ + { + name: "MCP Apps enabled defers to form", + enabledFlags: []string{MCPAppsFeatureFlag}, + want: true, + }, + { + name: "form deferral disabled executes directly", + enabledFlags: []string{ + MCPAppsFeatureFlag, + MCPAppsDisableFormDeferralFeatureFlag, + }, + want: false, + }, + { + name: "form deferral opt-out does not enable MCP Apps", + enabledFlags: []string{MCPAppsDisableFormDeferralFeatureFlag}, + want: false, + }, + { + name: "MCP Apps disabled executes directly", + want: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + deps := BaseDeps{featureChecker: featureCheckerFor(tc.enabledFlags...)} + assert.Equal(t, tc.want, shouldDeferToForm(ctx, deps, nil, args, formParams)) + }) + } +} diff --git a/pkg/http/server_test.go b/pkg/http/server_test.go index b509876d9e..d96f8a76e5 100644 --- a/pkg/http/server_test.go +++ b/pkg/http/server_test.go @@ -38,6 +38,12 @@ func TestCreateHTTPFeatureChecker(t *testing.T) { headerFeatures: []string{github.MCPAppsFeatureFlag}, wantEnabled: true, }, + { + name: "MCP Apps form deferral opt-out accepted from header", + flagName: github.MCPAppsDisableFormDeferralFeatureFlag, + headerFeatures: []string{github.MCPAppsDisableFormDeferralFeatureFlag}, + wantEnabled: true, + }, { name: "unknown flag in header is ignored", flagName: "unknown_flag", @@ -74,6 +80,12 @@ func TestCreateHTTPFeatureChecker(t *testing.T) { insidersMode: true, wantEnabled: true, }, + { + name: "insiders mode does not disable MCP Apps form deferral", + flagName: github.MCPAppsDisableFormDeferralFeatureFlag, + insidersMode: true, + wantEnabled: false, + }, { name: "static feature is enabled without header", staticFeatures: []string{github.FeatureFlagCSVOutput}, From d1dd472bcfb7be1df5ec19ed4a5ef0b566eff478 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 22 Jul 2026 19:05:23 +0000 Subject: [PATCH 34/35] build(deps): bump the npm_and_yarn group across 1 directory with 2 updates Bumps the npm_and_yarn group with 2 updates in the /ui directory: [body-parser](https://github.com/expressjs/body-parser) and [hono](https://github.com/honojs/hono). Updates `body-parser` from 2.2.2 to 2.3.0 - [Release notes](https://github.com/expressjs/body-parser/releases) - [Changelog](https://github.com/expressjs/body-parser/blob/master/HISTORY.md) - [Commits](https://github.com/expressjs/body-parser/compare/v2.2.2...v2.3.0) Updates `hono` from 4.12.26 to 4.12.31 - [Release notes](https://github.com/honojs/hono/releases) - [Commits](https://github.com/honojs/hono/compare/v4.12.26...v4.12.31) --- updated-dependencies: - dependency-name: body-parser dependency-version: 2.3.0 dependency-type: indirect dependency-group: npm_and_yarn - dependency-name: hono dependency-version: 4.12.31 dependency-type: indirect dependency-group: npm_and_yarn ... Signed-off-by: dependabot[bot] --- ui/package-lock.json | 38 ++++++++++++++++++++++++++------------ 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/ui/package-lock.json b/ui/package-lock.json index 71075e1711..ba3c2162f0 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -2089,21 +2089,21 @@ } }, "node_modules/body-parser": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", - "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", "license": "MIT", "peer": true, "dependencies": { "bytes": "^3.1.2", - "content-type": "^1.0.5", + "content-type": "^2.0.0", "debug": "^4.4.3", - "http-errors": "^2.0.0", - "iconv-lite": "^0.7.0", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", - "qs": "^6.14.1", - "raw-body": "^3.0.1", - "type-is": "^2.0.1" + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" }, "engines": { "node": ">=18" @@ -2113,6 +2113,20 @@ "url": "https://opencollective.com/express" } }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/braces": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", @@ -3003,9 +3017,9 @@ "peer": true }, "node_modules/hono": { - "version": "4.12.26", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.26.tgz", - "integrity": "sha512-uyZtpnYxM9CmQ7QsQknM4zN8EftNqhON1qYeIKM0Se67CCEe2c44xyGURwB0axX2fBDu1dqHrHAc1hmNT8ITkw==", + "version": "4.12.31", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.31.tgz", + "integrity": "sha512-zJIHFrl6bq3RDd2YusFNCDlM8qUprxKswyi/OPzPyzKDdyBXDqWx8bZlZ7R+saTdSTatUmb3O7K4SspGPaEOQg==", "license": "MIT", "peer": true, "engines": { From eb088dfe9d854dab6453a8d4ae5871a5ced20974 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 23 Jul 2026 06:51:02 +0000 Subject: [PATCH 35/35] fix: bump Node.js from 20 to 22 in build-ui action --- .github/actions/build-ui/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/build-ui/action.yml b/.github/actions/build-ui/action.yml index 46308ba0f8..1ab6dc4e73 100644 --- a/.github/actions/build-ui/action.yml +++ b/.github/actions/build-ui/action.yml @@ -20,7 +20,7 @@ runs: if: steps.cache-ui.outputs.cache-hit != 'true' uses: actions/setup-node@v6 with: - node-version: "20" + node-version: "22" cache: npm cache-dependency-path: ui/package-lock.json