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 diff --git a/.github/workflows/code-scanning.yml b/.github/workflows/code-scanning.yml index 3abb3f8fd5..26459e71cd 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 }} @@ -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" diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 51c3e8d8b0..0ddb8acabb 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -54,13 +54,13 @@ 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 - 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 }} @@ -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: | @@ -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') }} 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" diff --git a/Dockerfile b/Dockerfile index 688e5470aa..cb2ce8df12 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 @@ -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 @@ -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:348dac1808083ccc3366399d6db835875b4eaf7c9b694783f5a3f353c4b58a28 # Add required MCP server annotation LABEL io.modelcontextprotocol.server.name="io.github.github/github-mcp-server" diff --git a/README.md b/README.md index 10d987a262..1a06c0697d 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. +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): ```json @@ -590,6 +592,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 +753,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/cmd/github-mcp-server/main.go b/cmd/github-mcp-server/main.go index 231b0cf2c3..7671706b57 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,12 @@ var ( Long: `Start a server that communicates via standard input/output streams using JSON-RPC messages.`, RunE: func(_ *cobra.Command, _ []string) error { token := viper.GetString("personal_access_token") + appID := viper.GetString("app-id") + appInstallationID := viper.GetString("app-installation-id") + appPrivateKeyPath := viper.GetString("app-private-key-path") + appPrivateKeyInline := viper.GetString("app-private-key") + appAuthRequested := appID != "" || appInstallationID != "" || appPrivateKeyPath != "" || appPrivateKeyInline != "" + oauthClientID := viper.GetString("oauth-client-id") oauthClientSecret := viper.GetString("oauth-client-secret") // Fall back to the build-time baked-in client (official releases) when none is @@ -46,12 +55,18 @@ var ( // GITHUB_HOST=github.com (or api.github.com) still counts as the default and keeps // zero-config login working. The secret tracks the id, so an explicitly provided // id with no secret never picks up the baked-in secret. - if oauthClientID == "" && oauth.NormalizeHost(viper.GetString("host")) == "https://github.com" { + if oauthClientID == "" && !appAuthRequested && oauth.NormalizeHost(viper.GetString("host")) == "https://github.com" { oauthClientID = buildinfo.OAuthClientID oauthClientSecret = buildinfo.OAuthClientSecret } - if token == "" && oauthClientID == "" { - return errors.New("authentication required: set GITHUB_PERSONAL_ACCESS_TOKEN, or pass --oauth-client-id to log in via OAuth") + if token == "" && !appAuthRequested && oauthClientID == "" { + return errors.New("authentication required: set GITHUB_PERSONAL_ACCESS_TOKEN, configure GitHub App auth, or pass --oauth-client-id to log in via OAuth") + } + if appAuthRequested && token != "" { + return errors.New("GitHub App authentication and GITHUB_PERSONAL_ACCESS_TOKEN are mutually exclusive: set only one") + } + if appAuthRequested && oauthClientID != "" { + return errors.New("GitHub App authentication and OAuth login (--oauth-client-id) are mutually exclusive: set only one") } // If you're wondering why we're not using viper.GetStringSlice("toolsets"), @@ -116,7 +131,7 @@ var ( // client. The requested scopes default to the full supported set // (which filters out no tools); an explicit, narrower --oauth-scopes // both narrows the grant and hides tools needing other scopes. - if token == "" { + if token == "" && !appAuthRequested { scopes := ghoauth.SupportedScopes if viper.IsSet("oauth-scopes") { if err := viper.UnmarshalKey("oauth-scopes", &scopes); err != nil { @@ -134,6 +149,14 @@ var ( stdioServerConfig.OAuthScopes = scopes } + if appAuthRequested { + tokenProvider, err := newGitHubAppTokenProvider(appID, appInstallationID, appPrivateKeyPath, appPrivateKeyInline, viper.GetString("host")) + if err != nil { + return err + } + stdioServerConfig.TokenProvider = tokenProvider + } + return ghmcp.RunStdioServer(stdioServerConfig) }, } @@ -230,6 +253,11 @@ func init() { stdioCmd.Flags().StringSlice("oauth-scopes", nil, "Comma-separated OAuth scopes to request; also filters tools to those scopes. Defaults to the full supported set") stdioCmd.Flags().Int("oauth-callback-port", 0, "Fixed local port for the OAuth callback server. Defaults to a random port; set a fixed port when mapping it through Docker") + // The private key has no flag because passing it in argv would expose it. + stdioCmd.Flags().String("app-id", "", "GitHub App ID or client ID, enabling non-interactive server-to-server authentication") + stdioCmd.Flags().String("app-installation-id", "", "GitHub App installation ID to mint installation access tokens for") + stdioCmd.Flags().String("app-private-key-path", "", "Path to the GitHub App private key (PEM). Preferred over GITHUB_APP_PRIVATE_KEY: keeps the key off the command line and out of the environment") + // HTTP-specific flags httpCmd.Flags().Int("port", 8082, "HTTP server port") httpCmd.Flags().String("listen-host", "", "Host the HTTP server binds to (e.g. 127.0.0.1). Empty binds to all interfaces.") @@ -256,6 +284,9 @@ func init() { _ = viper.BindPFlag("oauth-client-secret", stdioCmd.Flags().Lookup("oauth-client-secret")) _ = viper.BindPFlag("oauth-scopes", stdioCmd.Flags().Lookup("oauth-scopes")) _ = viper.BindPFlag("oauth-callback-port", stdioCmd.Flags().Lookup("oauth-callback-port")) + _ = viper.BindPFlag("app-id", stdioCmd.Flags().Lookup("app-id")) + _ = viper.BindPFlag("app-installation-id", stdioCmd.Flags().Lookup("app-installation-id")) + _ = viper.BindPFlag("app-private-key-path", stdioCmd.Flags().Lookup("app-private-key-path")) _ = viper.BindPFlag("port", httpCmd.Flags().Lookup("port")) _ = viper.BindPFlag("listen-host", httpCmd.Flags().Lookup("listen-host")) _ = viper.BindPFlag("base-url", httpCmd.Flags().Lookup("base-url")) @@ -281,6 +312,48 @@ func main() { } } +func newGitHubAppTokenProvider(appID, installationID, keyPath, keyInline, host string) (func() string, error) { + keyBytes, err := loadAppPrivateKey(keyPath, keyInline) + if err != nil { + return nil, err + } + + apiHost, err := utils.NewAPIHost(host) + if err != nil { + return nil, fmt.Errorf("failed to parse host for GitHub App authentication: %w", err) + } + restURL, err := apiHost.BaseRESTURL(context.Background()) + if err != nil { + return nil, fmt.Errorf("failed to resolve REST URL for GitHub App authentication: %w", err) + } + + provider, err := githubapp.NewProvider(githubapp.Config{ + AppID: appID, + InstallationID: installationID, + PrivateKeyPEM: keyBytes, + BaseRESTURL: restURL.String(), + }, nil) + if err != nil { + return nil, fmt.Errorf("failed to configure GitHub App authentication: %w", err) + } + return provider.AccessToken, nil +} + +func loadAppPrivateKey(path, inline string) ([]byte, error) { + switch { + case path != "": + data, err := os.ReadFile(path) //#nosec G304 -- operator-supplied path to their own key + if err != nil { + return nil, fmt.Errorf("reading GitHub App private key file: %w", err) + } + return data, nil + case inline != "": + return []byte(strings.ReplaceAll(inline, `\n`, "\n")), nil + default: + return nil, errors.New("GitHub App authentication requires a private key: set GITHUB_APP_PRIVATE_KEY_PATH (preferred) or GITHUB_APP_PRIVATE_KEY") + } +} + func wordSepNormalizeFunc(_ *pflag.FlagSet, name string) pflag.NormalizedName { from := []string{"_"} to := "-" 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 new file mode 100644 index 0000000000..f1da08c7bc --- /dev/null +++ b/docs/github-app-auth.md @@ -0,0 +1,73 @@ +# GitHub App authentication + +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. + +This authentication mode is not available for the `http` command. HTTP clients +must continue to provide their own `Authorization` token. + +> [!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 + +Configure exactly one of a Personal Access Token, OAuth login, or GitHub App +authentication. + +| Flag | Environment variable | Description | +|------|----------------------|-------------| +| `--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 | + +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. + +## Usage + +```bash +github-mcp-server stdio \ + --app-id 123456 \ + --app-installation-id 7891011 \ + --app-private-key-path /secrets/github-app.pem +``` + +The equivalent environment configuration is: + +```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 +``` + +For Docker, mount the key read-only: + +```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 +``` + +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 + +- **Private key required**: set `GITHUB_APP_PRIVATE_KEY_PATH` or + `GITHUB_APP_PRIVATE_KEY`. +- **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 16c5dab67e..92fc79c9df 100644 --- a/docs/oauth-login.md +++ b/docs/oauth-login.md @@ -15,6 +15,9 @@ 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). +> For non-interactive stdio deployments, see +> [GitHub App authentication](github-app-auth.md). + ## Contents - [How it works](#how-it-works) 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/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/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/go.mod b/go.mod index 358a271a7a..1d8801f5a0 100644 --- a/go.mod +++ b/go.mod @@ -1,16 +1,16 @@ module github.com/github/github-mcp-server -go 1.25.0 +go 1.25.12 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 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.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 @@ -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 f3f23b549a..f6a655d510 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= @@ -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.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= @@ -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= diff --git a/internal/ghmcp/oauth.go b/internal/ghmcp/oauth.go index abc6d3d11c..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,41 +94,187 @@ 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, flowID string) (*oauth.Outcome, error) + Cancel(flowID string) bool } -// 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. +// 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 +// 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 +} + +// 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 -// flow runs: secure channels (browser, URL elicitation) block until the token -// arrives and then the call proceeds; the last-resort channel returns the -// instruction to the user as a tool result and asks them to retry. -func createOAuthMiddleware(mgr oauthAuthenticator, logger *slog.Logger) func(next mcp.MethodHandler) mcp.MethodHandler { - return func(next mcp.MethodHandler) mcp.MethodHandler { - return func(ctx context.Context, method string, request mcp.Request) (mcp.Result, error) { - if method != "tools/call" || mgr.HasToken() { - return next(ctx, method, request) +// 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 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) } - - outcome, err := mgr.Authenticate(ctx, &sessionPrompter{session: callReq.Session}) - if err != nil { - return nil, fmt.Errorf("github authorization failed: %w", err) + if serverMayInitiateElicitation(req.Session) { + return authorizeViaServerElicitation(ctx, mgr, next, req, logger) } - 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) + return startMultiRoundTripAuthorization(ctx, mgr, next, req, 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.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) + } + 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, req) +} + +// 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 + } + result, _ := response.(*mcp.ElicitResult) + return flowID, result, true + } + return "", nil, false +} + +// 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) + } + if outcome == nil || outcome.UserAction == nil { + // Already authorized (e.g. the server opened a browser and the flow + // completed); proceed. + return next(ctx, req) + } + + 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), 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}}, + }, nil + } + logger.Info("requesting github authorization via elicitation") + return &mcp.CallToolResult{ + 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 +// 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: ua.Message} + default: + return nil } } diff --git a/internal/ghmcp/oauth_test.go b/internal/ghmcp/oauth_test.go index 732d080e40..b358876232 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" @@ -140,57 +141,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 +240,18 @@ 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 + cancelResult bool + lastAwaitFlowID string + lastCancelFlowID string } func (f *fakeAuthenticator) HasToken() bool { return f.hasToken } @@ -257,34 +262,38 @@ func (f *fakeAuthenticator) Authenticate(_ context.Context, prompter oauth.Promp return f.outcome, f.err } -func TestCreateOAuthMiddleware(t *testing.T) { +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(flowID string) bool { + f.cancelCalls++ + f.lastCancelFlowID = flowID + return f.cancelResult +} + +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") @@ -294,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) { @@ -310,48 +317,261 @@ 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") }) } -// 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) { +// 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 + 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: 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() - mgr := oauth.NewManager(oauth.NewGitHubConfig("client-id", "", nil, "", 0), discardLogger()) - err := RunStdioServer(StdioServerConfig{ - Token: "ghp_static", - OAuthManager: mgr, + 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"}, FlowID: "flow-1"}, + 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.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") + }) + + 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"}, FlowID: "flow-1"}, + cancelResult: true, + } + 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.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) }) - require.Error(t, err) - assert.Contains(t, err.Error(), "mutually exclusive") + + 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}, FlowID: "flow-1"}, + } + + // 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) + }) +} + +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) +} + +func TestRunStdioServerRejectsMultipleAuthModes(t *testing.T) { + t.Parallel() + + mgr := oauth.NewManager(oauth.NewGitHubConfig("client-id", "", nil, "", 0), discardLogger()) + + tests := []struct { + name string + cfg StdioServerConfig + }{ + { + name: "token and oauth", + cfg: StdioServerConfig{Token: "ghp_static", OAuthManager: mgr}, + }, + { + name: "token and provider", + cfg: StdioServerConfig{Token: "ghp_static", TokenProvider: func() string { return "token" }}, + }, + { + name: "oauth and provider", + cfg: StdioServerConfig{OAuthManager: mgr, TokenProvider: func() string { return "token" }}, + }, + } + 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 -// 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 2267dd5d62..f13bdc476e 100644 --- a/internal/ghmcp/server.go +++ b/internal/ghmcp/server.go @@ -62,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. @@ -257,15 +257,21 @@ type StdioServerConfig struct { // are hidden. The default set is the full supported list, which hides // nothing; an explicit, narrower list filters accordingly. OAuthScopes []string + + // TokenProvider supplies a token for each GitHub API request. + TokenProvider func() string } // RunStdioServer is not concurrent safe. func RunStdioServer(cfg StdioServerConfig) error { - // OAuth login and a static token are mutually exclusive: they would - // disagree on how the token is sourced (lazy provider vs. static) and on - // scope filtering, so reject the ambiguous combination up front. - if cfg.OAuthManager != nil && cfg.Token != "" { - return fmt.Errorf("OAuthManager and a static Token are mutually exclusive: provide one or the other") + authModes := 0 + 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, or TokenProvider") } // Create app context @@ -311,41 +317,36 @@ 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. - var tokenProvider func() string + tokenProvider := cfg.TokenProvider + 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/githubapp/githubapp.go b/internal/githubapp/githubapp.go new file mode 100644 index 0000000000..bdd04af2cd --- /dev/null +++ b/internal/githubapp/githubapp.go @@ -0,0 +1,221 @@ +// Package githubapp provides GitHub App installation access tokens. +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" + "strings" + "sync" + "time" + + "golang.org/x/oauth2" +) + +const ( + jwtLifetime = 9 * time.Minute + clockSkew = time.Minute + refreshBuffer = 5 * time.Minute + httpTimeout = 30 * time.Second +) + +// Config describes a GitHub App installation used for server-to-server auth. +type Config struct { + // 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 + + // 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 +} + +func (c Config) validate() error { + switch { + case c.AppID == "": + 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 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") + } + return nil +} + +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 +} + +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": 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, privateKey, crypto.SHA256, digest[:]) + if err != nil { + return "", fmt.Errorf("signing JWT: %w", err) + } + + return signingInput + "." + base64.RawURLEncoding.EncodeToString(signature), nil +} + +type installationTokenSource struct { + cfg Config + privateKey *rsa.PrivateKey + httpClient *http.Client +} + +func newInstallationTokenSource(cfg Config, privateKey *rsa.PrivateKey, httpClient *http.Client) *installationTokenSource { + if httpClient == nil { + httpClient = &http.Client{Timeout: httpTimeout} + } + return &installationTokenSource{cfg: cfg, privateKey: privateKey, httpClient: httpClient} +} + +func (s *installationTokenSource) Token() (*oauth2.Token, error) { + jwt, err := mintJWT(s.cfg.AppID, s.privateKey, 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 { + 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))) + } + + 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") + } + 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: body.ExpiresAt.Add(-refreshBuffer), + }, nil +} + +// Provider caches and refreshes GitHub App installation access tokens. +type Provider struct { + source oauth2.TokenSource + logger *slog.Logger + + mu sync.Mutex + errLogged bool +} + +func NewProvider(cfg Config, logger *slog.Logger) (*Provider, error) { + 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.Default() + } + source := oauth2.ReuseTokenSource(nil, newInstallationTokenSource(cfg, privateKey, nil)) + return &Provider{source: source, logger: logger}, nil +} + +// AccessToken returns a cached token or refreshes it before expiry. +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 +} diff --git a/internal/githubapp/githubapp_test.go b/internal/githubapp/githubapp_test.go new file mode 100644 index 0000000000..6828dbc2dc --- /dev/null +++ b/internal/githubapp/githubapp_test.go @@ -0,0 +1,290 @@ +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 pkcs1PEMBytes(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", 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 or client ID is required"}, + {"missing installation id", func(c *Config) { c.InstallationID = "" }, "installation ID 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() + 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) + now := time.Now() + token, err := mintJWT("my-app-id", key, 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", 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) { + 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.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 := newTestTokenSource(t, 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 or client ID is required") +} + +func TestSourceRejectsIncompleteTokenResponse(t *testing.T) { + key := newTestKey(t) + 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/internal/oauth/manager.go b/internal/oauth/manager.go index a78e919df7..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,9 +75,12 @@ 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 lastErr error refreshErrLogged bool // true once a refresh failure has been logged, reset on re-auth } @@ -93,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. @@ -109,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. @@ -137,63 +160,142 @@ 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.cancelFlow = cancel m.mu.Unlock() - - bgCtx, cancel := context.WithTimeout(context.Background(), DefaultAuthTimeout) - 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 +// 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, 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(flowID) + } + select { + case <-done: + return m.outcomeAfterFlow(flowID) + case <-ctx.Done(): + return nil, ctx.Err() + } +} + +// 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 { @@ -212,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. @@ -223,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 @@ -248,12 +350,16 @@ 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 + m.cancelFlow = nil if err != nil { m.lastErr = err m.logger.Debug("oauth flow failed", "error", err) @@ -265,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") } @@ -277,28 +384,39 @@ 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: - 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(flowID) 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(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, FlowID: flowID}, 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/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/__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/__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/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/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/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/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 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..efba381147 --- /dev/null +++ b/pkg/github/lockdown_test.go @@ -0,0 +1,38 @@ +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) + }) + + 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") + }) +} 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)) +} diff --git a/pkg/github/projects_resolver.go b/pkg/github/projects_resolver.go index a5c93b5b93..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,23 +270,34 @@ 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 + } + } + PageInfo PageInfoFragment } - var query struct { + 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,21 +308,41 @@ 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 { - return 0, fmt.Errorf("failed to resolve project item for %s/%s#%d: %w", issueOwner, issueRepo, issueNumber, err) + 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 { + 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 fmt.Sprintf("%v", item.ID), parsedItemID, 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( + 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 e701441c1e..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( @@ -204,6 +262,7 @@ type resolveItemByIssueQuery struct { Issue struct { ProjectItems struct { Nodes []struct { + ID githubv4.ID FullDatabaseID githubv4.String `graphql:"fullDatabaseId"` Project struct { ID githubv4.ID @@ -215,7 +274,34 @@ type resolveItemByIssueQuery struct { } `graphql:"repository(owner: $issueOwner, name: $issueRepo)"` } -func Test_ResolveProjectItemIDByIssueNumber_Success(t *testing.T) { +type resolveItemByIssuePageQuery struct { + Repository struct { + Issue struct { + ProjectItems struct { + Nodes []struct { + ID githubv4.ID + 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_ResolveProjectItemByIssueNumber_Success(t *testing.T) { mocked := githubv4mock.NewMockedHTTPClient( // project node id lookup (org) githubv4mock.NewQueryMatcher( @@ -256,6 +342,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"}, }, @@ -274,8 +361,96 @@ 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_ResolveProjectItemByIssueNumber_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{ + "id": "PVTI_target", + "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) + + 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) } @@ -340,6 +515,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 +624,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( diff --git a/pkg/github/pullrequests.go b/pkg/github/pullrequests.go index 942cfd3a91..daf3b97331 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) @@ -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 } } @@ -217,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, @@ -358,7 +380,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, @@ -563,17 +589,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 c032502c97..ace47c666b 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" @@ -53,12 +54,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 +94,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 +133,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) @@ -1152,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", @@ -1221,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 { @@ -1228,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) @@ -2389,6 +2496,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 { @@ -3794,10 +3928,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 }{ @@ -3816,6 +3970,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 { @@ -3825,10 +4010,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) diff --git a/pkg/github/server.go b/pkg/github/server.go index 627cc678b2..43e0940017 100644 --- a/pkg/github/server.go +++ b/pkg/github/server.go @@ -69,10 +69,14 @@ 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 + // 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 +109,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/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), 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}, 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) } 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", 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" diff --git a/third-party-licenses.darwin.md b/third-party-licenses.darwin.md index 88235f3f40..2bf5e86eae 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)) @@ -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.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)) @@ -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 e3762f5c04..4caa5f58b2 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)) @@ -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.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)) @@ -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 eb0743558a..a7164a2aad 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)) @@ -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.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)) @@ -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)) diff --git a/ui/package-lock.json b/ui/package-lock.json index 0716e12068..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", @@ -2730,9 +2744,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", @@ -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": {