diff --git a/.github/actions/build-ui/action.yml b/.github/actions/build-ui/action.yml index 229057d5cb..46308ba0f8 100644 --- a/.github/actions/build-ui/action.yml +++ b/.github/actions/build-ui/action.yml @@ -12,7 +12,8 @@ runs: pkg/github/ui_dist/get-me.html pkg/github/ui_dist/issue-write.html pkg/github/ui_dist/pr-write.html - key: ui-dist-v1-${{ hashFiles('ui/package-lock.json', 'ui/package.json', 'ui/index.html', 'ui/tsconfig*.json', 'ui/vite.config.ts', 'ui/src/**', 'ui/scripts/**') }} + pkg/github/ui_dist/pr-edit.html + key: ui-dist-v2-${{ hashFiles('ui/package-lock.json', 'ui/package.json', 'ui/index.html', 'ui/tsconfig*.json', 'ui/vite.config.ts', 'ui/src/**', 'ui/scripts/**') }} enableCrossOsArchive: true - name: Set up Node.js diff --git a/Dockerfile b/Dockerfile index a4ea1d03b8..132752fde4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM node:26-alpine@sha256:144769ec3f32e8ee36b3cfde91e82bee25d9367b20f31a151f3f7eea3a2a8541 AS ui-build +FROM node:26-alpine@sha256:3ad34ca6292aec4a91d8ddeb9229e29d9c2f689efd0dd242860889ac71842eba 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:cd2fb3559df6e13bc93b7f0734a4eabe1d21e7b64eec211ed90784f00a17a56a AS build +FROM golang:1.25.11-alpine@sha256:8d95af53d0d58e1759ddb4028285d9b1239067e4fbf4f544618cad0f60fbc354 AS build ARG VERSION="dev" # Set the working directory @@ -30,7 +30,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:58695f439f772a00009c8f6be4c183f824c1f556d74b313c30900f167e4772f8 +FROM gcr.io/distroless/base-debian12@sha256:e7e678c88c59e70e105a46549bb3fbfb3d732ee3b4afd3a19fdab2e15afaa6b3 # 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 dc063f22ce..404135aedc 100644 --- a/README.md +++ b/README.md @@ -555,6 +555,7 @@ The following sets of tools are available: | --- | ----------------------- | ------------------------------------------------------------- | | person | `context` | **Strongly recommended**: Tools that provide context about the current user and GitHub context you are operating in | | workflow | `actions` | GitHub Actions workflows and CI/CD operations | +| 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 | | dependabot | `dependabot` | Dependabot tools | @@ -640,6 +641,18 @@ The following sets of tools are available:
+code-square Code Quality + +- **get_code_quality_finding** - Get code quality finding + - **Required OAuth Scopes**: `repo` + - `findingNumber`: The number of the finding. (number, required) + - `owner`: The owner of the repository. (string, required) + - `repo`: The name of the repository. (string, required) + +
+ +
+ codescan Code Security - **get_code_scanning_alert** - Get code scanning alert @@ -872,12 +885,13 @@ The following sets of tools are available: - `state`: New state (string, optional) - `state_reason`: Reason for the state change. Ignored unless state is changed. (string, optional) - `title`: Issue title (string, optional) - - `type`: Type of this issue. Only use if the repository has issue types configured. Use list_issue_types tool to get valid type values for the organization. If the repository doesn't support issue types, omit this parameter. (string, optional) + - `type`: Type of this issue. Only use if issue types are enabled for this repository. Use list_issue_types tool to get valid type values for this repository or its owner organization. If the repository doesn't support issue types, omit this parameter. (string, optional) - **list_issue_types** - List available issue types - - **Required OAuth Scopes**: `read:org` - - **Accepted OAuth Scopes**: `admin:org`, `read:org`, `write:org` - - `owner`: The organization owner of the repository (string, required) + - **Required OAuth Scopes (any of)**: `repo`, `read:org` + - **Accepted OAuth Scopes**: `admin:org`, `read:org`, `repo`, `write:org` + - `owner`: The account owner of the repository or organization. (string, required) + - `repo`: The name of the repository. When provided, returns issue types for this specific repository. When omitted, returns org-level issue types directly. (string, optional) - **list_issues** - List issues - **Required OAuth Scopes**: `repo` @@ -1090,6 +1104,7 @@ The following sets of tools are available: - `maintainer_can_modify`: Allow maintainer edits (boolean, optional) - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) + - `reviewers`: GitHub usernames or ORG/team-slug team reviewers to request reviews from (string[], optional) - `title`: PR title (string, required) - **list_pull_requests** - List pull requests @@ -1204,7 +1219,7 @@ The following sets of tools are available: - `description`: Repository description (string, optional) - `name`: Repository name (string, required) - `organization`: Organization to create the repository in (omit to create in your personal account) (string, optional) - - `private`: Whether repo should be private (boolean, optional) + - `private`: Whether the repository should be private. Defaults to true (private) when omitted. (boolean, optional) - **delete_file** - Delete file - **Required OAuth Scopes**: `repo` diff --git a/cmd/github-mcp-server/generate_docs.go b/cmd/github-mcp-server/generate_docs.go index 78ed8361a8..e8c044cde1 100644 --- a/cmd/github-mcp-server/generate_docs.go +++ b/cmd/github-mcp-server/generate_docs.go @@ -221,7 +221,15 @@ func writeToolDoc(buf *strings.Builder, tool inventory.ServerTool) { // OAuth scopes if present if len(tool.RequiredScopes) > 0 { - fmt.Fprintf(buf, " - **Required OAuth Scopes**: `%s`\n", strings.Join(tool.RequiredScopes, "`, `")) + // Scope filtering uses "any of" semantics (see scopes.HasRequiredScopes), + // so when multiple required scopes are listed, render them as alternatives + // rather than implying all are required. + scopeList := "`" + strings.Join(tool.RequiredScopes, "`, `") + "`" + if len(tool.RequiredScopes) > 1 { + fmt.Fprintf(buf, " - **Required OAuth Scopes (any of)**: %s\n", scopeList) + } else { + fmt.Fprintf(buf, " - **Required OAuth Scopes**: %s\n", scopeList) + } // Only show accepted scopes if they differ from required scopes if len(tool.AcceptedScopes) > 0 && !scopesEqual(tool.RequiredScopes, tool.AcceptedScopes) { @@ -257,6 +265,8 @@ func writeToolDoc(buf *strings.Builder, tool inventory.ServerTool) { } sort.Strings(paramNames) + conditional := inventory.ConditionalSchemaPropertyDescriptions() + for i, propName := range paramNames { prop := schema.Properties[propName] required := slices.Contains(schema.Required, propName) @@ -282,7 +292,11 @@ func writeToolDoc(buf *strings.Builder, tool inventory.ServerTool) { // Indent any continuation lines in the description to maintain markdown formatting description := indentMultilineDescription(prop.Description, " ") - fmt.Fprintf(buf, " - `%s`: %s (%s, %s)", propName, description, typeStr, requiredStr) + if cond, isConditional := conditional[propName]; isConditional { + fmt.Fprintf(buf, " - `%s`: %s (%s, %s, conditional — %s)", propName, description, typeStr, requiredStr, cond) + } else { + fmt.Fprintf(buf, " - `%s`: %s (%s, %s)", propName, description, typeStr, requiredStr) + } if i < len(paramNames)-1 { buf.WriteString("\n") } diff --git a/cmd/github-mcp-server/main.go b/cmd/github-mcp-server/main.go index 558fdb9980..604556692c 100644 --- a/cmd/github-mcp-server/main.go +++ b/cmd/github-mcp-server/main.go @@ -138,6 +138,7 @@ var ( Version: version, Host: viper.GetString("host"), Port: viper.GetInt("port"), + ListenHost: viper.GetString("listen-host"), BaseURL: viper.GetString("base-url"), ResourcePath: viper.GetString("base-path"), ExportTranslations: viper.GetBool("export-translations"), @@ -184,6 +185,7 @@ func init() { // 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.") httpCmd.Flags().String("base-url", "", "Base URL where this server is publicly accessible (for OAuth resource metadata)") httpCmd.Flags().String("base-path", "", "Externally visible base path for the HTTP server (for OAuth resource metadata)") httpCmd.Flags().Bool("scope-challenge", false, "Enable OAuth scope challenge responses") @@ -204,6 +206,7 @@ func init() { _ = viper.BindPFlag("insiders", rootCmd.PersistentFlags().Lookup("insiders")) _ = viper.BindPFlag("repo-access-cache-ttl", rootCmd.PersistentFlags().Lookup("repo-access-cache-ttl")) _ = 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")) _ = viper.BindPFlag("base-path", httpCmd.Flags().Lookup("base-path")) _ = viper.BindPFlag("scope-challenge", httpCmd.Flags().Lookup("scope-challenge")) diff --git a/docs/feature-flags.md b/docs/feature-flags.md index cb02463a10..590cb65975 100644 --- a/docs/feature-flags.md +++ b/docs/feature-flags.md @@ -44,6 +44,8 @@ runtime behavior (such as output formatting) won't appear here. - `maintainer_can_modify`: Allow maintainer edits (boolean, optional) - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) + - `reviewers`: GitHub usernames or ORG/team-slug team reviewers to request reviews from (string[], optional) + - `show_ui`: Whether to render the MCP App form instead of executing the request immediately. Defaults to true. Set to false to skip the form and execute directly — useful when you have all required values (especially ones the form does not collect, like reviewers) and the user has already confirmed the action. (boolean, optional, conditional — visible when remote_mcp_ui_apps is enabled unless the client explicitly indicates it does not support io.modelcontextprotocol/ui) - `title`: PR title (string, required) - **get_me** - Get my user profile @@ -66,10 +68,32 @@ runtime behavior (such as output formatting) won't appear here. - `milestone`: Milestone number (number, optional) - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) + - `show_ui`: Whether to render the MCP App form instead of executing the request immediately. Defaults to true. Set to false to skip the form and execute directly — useful when you have all required values (especially ones the form does not collect, like labels, assignees, milestone, type, or state changes) and the user has already confirmed the action. (boolean, optional, conditional — visible when remote_mcp_ui_apps is enabled unless the client explicitly indicates it does not support io.modelcontextprotocol/ui) - `state`: New state (string, optional) - `state_reason`: Reason for the state change. Ignored unless state is changed. (string, optional) - `title`: Issue title (string, optional) - - `type`: Type of this issue. Only use if the repository has issue types configured. Use list_issue_types tool to get valid type values for the organization. If the repository doesn't support issue types, omit this parameter. (string, optional) + - `type`: Type of this issue. Only use if issue types are enabled for this repository. Use list_issue_types tool to get valid type values for this repository or its owner organization. If the repository doesn't support issue types, omit this parameter. (string, optional) + +- **ui_get** - Get UI data + - **Required OAuth Scopes**: `repo`, `read:org` + - **Accepted OAuth Scopes**: `admin:org`, `read:org`, `repo`, `write:org` + - `method`: The type of data to fetch (string, required) + - `owner`: Repository owner (required for all methods) (string, required) + - `repo`: Repository name (required for labels, assignees, milestones, branches, issue fields, reviewers) (string, optional) + +- **update_pull_request** - Edit pull request + - **Required OAuth Scopes**: `repo` + - **MCP App UI**: `ui://github-mcp-server/pr-edit` + - `base`: New base branch name (string, optional) + - `body`: New description (string, optional) + - `draft`: Mark pull request as draft (true) or ready for review (false) (boolean, optional) + - `maintainer_can_modify`: Allow maintainer edits (boolean, optional) + - `owner`: Repository owner (string, required) + - `pullNumber`: Pull request number to update (number, required) + - `repo`: Repository name (string, required) + - `reviewers`: GitHub usernames or ORG/team-slug team reviewers to request reviews from (string[], optional) + - `state`: New state (string, optional) + - `title`: New title (string, optional) ### `remote_mcp_issue_fields` @@ -92,10 +116,10 @@ runtime behavior (such as output formatting) won't appear here. - `state`: New state (string, optional) - `state_reason`: Reason for the state change. Ignored unless state is changed. (string, optional) - `title`: Issue title (string, optional) - - `type`: Type of this issue. Only use if the repository has issue types configured. Use list_issue_types tool to get valid type values for the organization. If the repository doesn't support issue types, omit this parameter. (string, optional) + - `type`: Type of this issue. Only use if issue types are enabled for this repository. Use list_issue_types tool to get valid type values for this repository or its owner organization. If the repository doesn't support issue types, omit this parameter. (string, optional) - **list_issue_fields** - List issue fields - - **Required OAuth Scopes**: `repo`, `read:org` + - **Required OAuth Scopes (any of)**: `repo`, `read:org` - **Accepted OAuth Scopes**: `admin:org`, `read:org`, `repo`, `write:org` - `owner`: The account owner of the repository or organization. The name is not case sensitive. (string, required) - `repo`: The name of the repository. When provided, returns fields for this specific repository (inherited from its organization). When omitted, returns org-level fields directly. (string, optional) @@ -198,7 +222,7 @@ runtime behavior (such as output formatting) won't appear here. - **update_issue_type** - Update Issue Type - **Required OAuth Scopes**: `repo` - - `confidence`: How confident you are in this choice. Use 'high' for clear signal or explicit user request, 'medium' for reasonable inference with some ambiguity, 'low' for best guess with limited signal. (string, optional) + - `confidence`: How confident you are in this choice. Use 'HIGH' for clear signal or explicit user request, 'MEDIUM' for reasonable inference with some ambiguity, 'LOW' for best guess with limited signal. (string, optional) - `is_suggestion`: If true, this issue type change is sent to the API as a suggestion (suggest:true) rather than an applied value. Whether the type is applied or recorded as a proposal is determined by the API. (boolean, optional) - `issue_number`: The issue number to update (number, required) - `issue_type`: The issue type to set (string, required) diff --git a/docs/insiders-features.md b/docs/insiders-features.md index 2277f0c8e2..3306b5cd85 100644 --- a/docs/insiders-features.md +++ b/docs/insiders-features.md @@ -38,6 +38,8 @@ The list below is generated from the Go source. It covers tool **inventory and s - `maintainer_can_modify`: Allow maintainer edits (boolean, optional) - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) + - `reviewers`: GitHub usernames or ORG/team-slug team reviewers to request reviews from (string[], optional) + - `show_ui`: Whether to render the MCP App form instead of executing the request immediately. Defaults to true. Set to false to skip the form and execute directly — useful when you have all required values (especially ones the form does not collect, like reviewers) and the user has already confirmed the action. (boolean, optional, conditional — visible when remote_mcp_ui_apps is enabled unless the client explicitly indicates it does not support io.modelcontextprotocol/ui) - `title`: PR title (string, required) - **get_me** - Get my user profile @@ -60,10 +62,32 @@ The list below is generated from the Go source. It covers tool **inventory and s - `milestone`: Milestone number (number, optional) - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) + - `show_ui`: Whether to render the MCP App form instead of executing the request immediately. Defaults to true. Set to false to skip the form and execute directly — useful when you have all required values (especially ones the form does not collect, like labels, assignees, milestone, type, or state changes) and the user has already confirmed the action. (boolean, optional, conditional — visible when remote_mcp_ui_apps is enabled unless the client explicitly indicates it does not support io.modelcontextprotocol/ui) - `state`: New state (string, optional) - `state_reason`: Reason for the state change. Ignored unless state is changed. (string, optional) - `title`: Issue title (string, optional) - - `type`: Type of this issue. Only use if the repository has issue types configured. Use list_issue_types tool to get valid type values for the organization. If the repository doesn't support issue types, omit this parameter. (string, optional) + - `type`: Type of this issue. Only use if issue types are enabled for this repository. Use list_issue_types tool to get valid type values for this repository or its owner organization. If the repository doesn't support issue types, omit this parameter. (string, optional) + +- **ui_get** - Get UI data + - **Required OAuth Scopes**: `repo`, `read:org` + - **Accepted OAuth Scopes**: `admin:org`, `read:org`, `repo`, `write:org` + - `method`: The type of data to fetch (string, required) + - `owner`: Repository owner (required for all methods) (string, required) + - `repo`: Repository name (required for labels, assignees, milestones, branches, issue fields, reviewers) (string, optional) + +- **update_pull_request** - Edit pull request + - **Required OAuth Scopes**: `repo` + - **MCP App UI**: `ui://github-mcp-server/pr-edit` + - `base`: New base branch name (string, optional) + - `body`: New description (string, optional) + - `draft`: Mark pull request as draft (true) or ready for review (false) (boolean, optional) + - `maintainer_can_modify`: Allow maintainer edits (boolean, optional) + - `owner`: Repository owner (string, required) + - `pullNumber`: Pull request number to update (number, required) + - `repo`: Repository name (string, required) + - `reviewers`: GitHub usernames or ORG/team-slug team reviewers to request reviews from (string[], optional) + - `state`: New state (string, optional) + - `title`: New title (string, optional) ### `remote_mcp_issue_fields` @@ -86,10 +110,10 @@ The list below is generated from the Go source. It covers tool **inventory and s - `state`: New state (string, optional) - `state_reason`: Reason for the state change. Ignored unless state is changed. (string, optional) - `title`: Issue title (string, optional) - - `type`: Type of this issue. Only use if the repository has issue types configured. Use list_issue_types tool to get valid type values for the organization. If the repository doesn't support issue types, omit this parameter. (string, optional) + - `type`: Type of this issue. Only use if issue types are enabled for this repository. Use list_issue_types tool to get valid type values for this repository or its owner organization. If the repository doesn't support issue types, omit this parameter. (string, optional) - **list_issue_fields** - List issue fields - - **Required OAuth Scopes**: `repo`, `read:org` + - **Required OAuth Scopes (any of)**: `repo`, `read:org` - **Accepted OAuth Scopes**: `admin:org`, `read:org`, `repo`, `write:org` - `owner`: The account owner of the repository or organization. The name is not case sensitive. (string, required) - `repo`: The name of the repository. When provided, returns fields for this specific repository (inherited from its organization). When omitted, returns org-level fields directly. (string, optional) diff --git a/docs/remote-server.md b/docs/remote-server.md index aa083d2f29..4665ba8044 100644 --- a/docs/remote-server.md +++ b/docs/remote-server.md @@ -22,6 +22,7 @@ Below is a table of available toolsets for the remote GitHub MCP Server. Each to | apps
`default` | Default toolset | https://api.githubcopilot.com/mcp/ | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2F%22%7D) | [read-only](https://api.githubcopilot.com/mcp/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Freadonly%22%7D) | | apps
`all` | All available GitHub MCP tools | https://api.githubcopilot.com/mcp/x/all | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-all&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fall%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/all/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-all&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fall%2Freadonly%22%7D) | | workflow
`actions` | GitHub Actions workflows and CI/CD operations | https://api.githubcopilot.com/mcp/x/actions | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-actions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Factions%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/actions/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-actions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Factions%2Freadonly%22%7D) | +| 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) | | 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) | diff --git a/docs/toolsets-and-icons.md b/docs/toolsets-and-icons.md index 9228248ecb..0e54b1f16a 100644 --- a/docs/toolsets-and-icons.md +++ b/docs/toolsets-and-icons.md @@ -151,6 +151,7 @@ icons := octicons.Icons("repo") | Users | `people` | | Organizations | `organization` | | Actions | `workflow` | +| Code Quality | `code-square` | | Code Security | `codescan` | | Secret Protection | `shield-lock` | | Dependabot | `dependabot` | diff --git a/pkg/errors/error_test.go b/pkg/errors/error_test.go index 77ceb21375..3c899b6b58 100644 --- a/pkg/errors/error_test.go +++ b/pkg/errors/error_test.go @@ -3,13 +3,13 @@ package errors import ( "context" "fmt" - "net/http" - "testing" - "time" "github.com/google/go-github/v87/github" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "net/http" + "testing" + "time" ) func TestGitHubErrorContext(t *testing.T) { @@ -687,4 +687,3 @@ func TestNewGitHubAPIErrorResponse_RateLimits(t *testing.T) { assert.Contains(t, text, "validation failed") }) } - diff --git a/pkg/github/__toolsnaps__/create_pull_request.snap b/pkg/github/__toolsnaps__/create_pull_request.snap index a8a94ce690..b2f14e3908 100644 --- a/pkg/github/__toolsnaps__/create_pull_request.snap +++ b/pkg/github/__toolsnaps__/create_pull_request.snap @@ -42,6 +42,17 @@ "description": "Repository name", "type": "string" }, + "reviewers": { + "description": "GitHub usernames or ORG/team-slug team reviewers to request reviews from", + "items": { + "type": "string" + }, + "type": "array" + }, + "show_ui": { + "description": "Whether to render the MCP App form instead of executing the request immediately. Defaults to true. Set to false to skip the form and execute directly — useful when you have all required values (especially ones the form does not collect, like reviewers) and the user has already confirmed the action.", + "type": "boolean" + }, "title": { "description": "PR title", "type": "string" diff --git a/pkg/github/__toolsnaps__/create_repository.snap b/pkg/github/__toolsnaps__/create_repository.snap index 2cc4227b23..0aa2123673 100644 --- a/pkg/github/__toolsnaps__/create_repository.snap +++ b/pkg/github/__toolsnaps__/create_repository.snap @@ -22,7 +22,8 @@ "type": "string" }, "private": { - "description": "Whether repo should be private", + "default": true, + "description": "Whether the repository should be private. Defaults to true (private) when omitted.", "type": "boolean" } }, diff --git a/pkg/github/__toolsnaps__/get_code_quality_finding.snap b/pkg/github/__toolsnaps__/get_code_quality_finding.snap new file mode 100644 index 0000000000..378efe835d --- /dev/null +++ b/pkg/github/__toolsnaps__/get_code_quality_finding.snap @@ -0,0 +1,30 @@ +{ + "annotations": { + "readOnlyHint": true, + "title": "Get code quality finding" + }, + "description": "Get details of a specific code quality finding in a GitHub repository.", + "inputSchema": { + "properties": { + "findingNumber": { + "description": "The number of the finding.", + "type": "number" + }, + "owner": { + "description": "The owner of the repository.", + "type": "string" + }, + "repo": { + "description": "The name of the repository.", + "type": "string" + } + }, + "required": [ + "owner", + "repo", + "findingNumber" + ], + "type": "object" + }, + "name": "get_code_quality_finding" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/issue_write.snap b/pkg/github/__toolsnaps__/issue_write.snap index 88b01f08f1..06be3b0278 100644 --- a/pkg/github/__toolsnaps__/issue_write.snap +++ b/pkg/github/__toolsnaps__/issue_write.snap @@ -60,6 +60,10 @@ "description": "Repository name", "type": "string" }, + "show_ui": { + "description": "Whether to render the MCP App form instead of executing the request immediately. Defaults to true. Set to false to skip the form and execute directly — useful when you have all required values (especially ones the form does not collect, like labels, assignees, milestone, type, or state changes) and the user has already confirmed the action.", + "type": "boolean" + }, "state": { "description": "New state", "enum": [ @@ -82,7 +86,7 @@ "type": "string" }, "type": { - "description": "Type of this issue. Only use if the repository has issue types configured. Use list_issue_types tool to get valid type values for the organization. If the repository doesn't support issue types, omit this parameter.", + "description": "Type of this issue. Only use if issue types are enabled for this repository. Use list_issue_types tool to get valid type values for this repository or its owner organization. If the repository doesn't support issue types, omit this parameter.", "type": "string" } }, diff --git a/pkg/github/__toolsnaps__/issue_write_ff_remote_mcp_issue_fields.snap b/pkg/github/__toolsnaps__/issue_write_ff_remote_mcp_issue_fields.snap index 332a4de3e1..47d00c4456 100644 --- a/pkg/github/__toolsnaps__/issue_write_ff_remote_mcp_issue_fields.snap +++ b/pkg/github/__toolsnaps__/issue_write_ff_remote_mcp_issue_fields.snap @@ -96,6 +96,10 @@ "description": "Repository name", "type": "string" }, + "show_ui": { + "description": "Whether to render the MCP App form instead of executing the request immediately. Defaults to true. Set to false to skip the form and execute directly — useful when you have all required values (especially ones the form does not collect, like labels, assignees, milestone, type, issue_fields, or state changes) and the user has already confirmed the action.", + "type": "boolean" + }, "state": { "description": "New state", "enum": [ @@ -118,7 +122,7 @@ "type": "string" }, "type": { - "description": "Type of this issue. Only use if the repository has issue types configured. Use list_issue_types tool to get valid type values for the organization. If the repository doesn't support issue types, omit this parameter.", + "description": "Type of this issue. Only use if issue types are enabled for this repository. Use list_issue_types tool to get valid type values for this repository or its owner organization. If the repository doesn't support issue types, omit this parameter.", "type": "string" } }, diff --git a/pkg/github/__toolsnaps__/list_issue_types.snap b/pkg/github/__toolsnaps__/list_issue_types.snap index f1f1377a81..283cb5a8de 100644 --- a/pkg/github/__toolsnaps__/list_issue_types.snap +++ b/pkg/github/__toolsnaps__/list_issue_types.snap @@ -3,11 +3,15 @@ "readOnlyHint": true, "title": "List available issue types" }, - "description": "List supported issue types for repository owner (organization).", + "description": "List supported issue types for a repository or its owner organization. When repo is omitted, returns org-level issue types directly.", "inputSchema": { "properties": { "owner": { - "description": "The organization owner of the repository", + "description": "The account owner of the repository or organization.", + "type": "string" + }, + "repo": { + "description": "The name of the repository. When provided, returns issue types for this specific repository. When omitted, returns org-level issue types directly.", "type": "string" } }, diff --git a/pkg/github/__toolsnaps__/set_issue_fields.snap b/pkg/github/__toolsnaps__/set_issue_fields.snap index 8f25d09699..7a98fde2aa 100644 --- a/pkg/github/__toolsnaps__/set_issue_fields.snap +++ b/pkg/github/__toolsnaps__/set_issue_fields.snap @@ -12,11 +12,11 @@ "items": { "properties": { "confidence": { - "description": "How confident you are in this choice. Use 'high' for clear signal or explicit user request, 'medium' for reasonable inference with some ambiguity, 'low' for best guess with limited signal.", + "description": "How confident you are in this choice. Use '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" + "LOW", + "MEDIUM", + "HIGH" ], "type": "string" }, diff --git a/pkg/github/__toolsnaps__/ui_get.snap b/pkg/github/__toolsnaps__/ui_get.snap new file mode 100644 index 0000000000..7f13d97c1c --- /dev/null +++ b/pkg/github/__toolsnaps__/ui_get.snap @@ -0,0 +1,45 @@ +{ + "_meta": { + "ui": { + "visibility": [ + "app" + ] + } + }, + "annotations": { + "readOnlyHint": true, + "title": "Get UI data" + }, + "description": "Fetch UI data for MCP Apps (labels, assignees, milestones, issue types, branches, issue fields, reviewers).", + "inputSchema": { + "properties": { + "method": { + "description": "The type of data to fetch", + "enum": [ + "labels", + "assignees", + "milestones", + "issue_types", + "branches", + "issue_fields", + "reviewers" + ], + "type": "string" + }, + "owner": { + "description": "Repository owner (required for all methods)", + "type": "string" + }, + "repo": { + "description": "Repository name (required for labels, assignees, milestones, branches, issue fields, reviewers)", + "type": "string" + } + }, + "required": [ + "method", + "owner" + ], + "type": "object" + }, + "name": "ui_get" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/update_issue_labels.snap b/pkg/github/__toolsnaps__/update_issue_labels.snap index 21f7fea6b6..2b31d756b0 100644 --- a/pkg/github/__toolsnaps__/update_issue_labels.snap +++ b/pkg/github/__toolsnaps__/update_issue_labels.snap @@ -4,7 +4,7 @@ "openWorldHint": true, "title": "Update Issue Labels" }, - "description": "Update the labels of an existing issue. This replaces the current labels with the provided list. When setting values, include a confidence level (low, medium, or high) reflecting how certain you are about the choice.", + "description": "Update the labels of an existing issue. This replaces the current labels with the provided list. When setting values, include a confidence level (LOW, MEDIUM, or HIGH) reflecting how certain you are about the choice.", "inputSchema": { "properties": { "issue_number": { @@ -23,11 +23,11 @@ { "properties": { "confidence": { - "description": "How confident you are in this choice. Use 'high' for clear signal or explicit user request, 'medium' for reasonable inference with some ambiguity, 'low' for best guess with limited signal.", + "description": "How confident you are in this choice. Use '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" + "LOW", + "MEDIUM", + "HIGH" ], "type": "string" }, diff --git a/pkg/github/__toolsnaps__/update_issue_type.snap b/pkg/github/__toolsnaps__/update_issue_type.snap index 2f39b2d3b8..d07a9d43d9 100644 --- a/pkg/github/__toolsnaps__/update_issue_type.snap +++ b/pkg/github/__toolsnaps__/update_issue_type.snap @@ -4,15 +4,15 @@ "openWorldHint": true, "title": "Update Issue Type" }, - "description": "Update the type of an existing issue (e.g. 'bug', 'feature'). When setting values, include a confidence level (low, medium, or high) reflecting how certain you are about the choice.", + "description": "Update the type of an existing issue (e.g. 'bug', 'feature'). When setting values, include a confidence level (LOW, MEDIUM, or HIGH) reflecting how certain you are about the choice.", "inputSchema": { "properties": { "confidence": { - "description": "How confident you are in this choice. Use 'high' for clear signal or explicit user request, 'medium' for reasonable inference with some ambiguity, 'low' for best guess with limited signal.", + "description": "How confident you are in this choice. Use '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" + "LOW", + "MEDIUM", + "HIGH" ], "type": "string" }, diff --git a/pkg/github/__toolsnaps__/update_pull_request.snap b/pkg/github/__toolsnaps__/update_pull_request.snap index 3d87fe75fe..cadc391ef4 100644 --- a/pkg/github/__toolsnaps__/update_pull_request.snap +++ b/pkg/github/__toolsnaps__/update_pull_request.snap @@ -1,4 +1,13 @@ { + "_meta": { + "ui": { + "resourceUri": "ui://github-mcp-server/pr-edit", + "visibility": [ + "model", + "app" + ] + } + }, "annotations": { "title": "Edit pull request" }, diff --git a/pkg/github/code_quality.go b/pkg/github/code_quality.go new file mode 100644 index 0000000000..41c791182b --- /dev/null +++ b/pkg/github/code_quality.go @@ -0,0 +1,99 @@ +package github + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" + + ghErrors "github.com/github/github-mcp-server/pkg/errors" + "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/scopes" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/github/github-mcp-server/pkg/utils" +) + +func GetCodeQualityFinding(t translations.TranslationHelperFunc) inventory.ServerTool { + return NewTool( + ToolsetMetadataCodeQuality, + mcp.Tool{ + Name: "get_code_quality_finding", + Description: t("TOOL_GET_CODE_QUALITY_FINDING_DESCRIPTION", "Get details of a specific code quality finding in a GitHub repository."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_GET_CODE_QUALITY_FINDING_USER_TITLE", "Get code quality finding"), + ReadOnlyHint: true, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": { + Type: "string", + Description: "The owner of the repository.", + }, + "repo": { + Type: "string", + Description: "The name of the repository.", + }, + "findingNumber": { + Type: "number", + Description: "The number of the finding.", + }, + }, + Required: []string{"owner", "repo", "findingNumber"}, + }, + }, + []scopes.Scope{scopes.Repo}, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + repo, err := RequiredParam[string](args, "repo") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + findingNumber, err := RequiredInt(args, "findingNumber") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + client, err := deps.GetClient(ctx) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil + } + + apiURL := fmt.Sprintf("repos/%s/%s/code-quality/findings/%d", owner, repo, findingNumber) + req, err := client.NewRequest(ctx, http.MethodGet, apiURL, nil) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to create request", err), nil, nil + } + + finding := make(map[string]any) + + resp, err := client.Do(req, &finding) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get finding", resp, err), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, err := io.ReadAll(resp.Body) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to read response body", err), nil, nil + } + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get finding", resp, body), nil, nil + } + + r, err := json.Marshal(finding) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to marshal finding", err), nil, nil + } + + return utils.NewToolResultText(string(r)), nil, nil + }, + ) +} diff --git a/pkg/github/code_quality_test.go b/pkg/github/code_quality_test.go new file mode 100644 index 0000000000..3971e5a0d6 --- /dev/null +++ b/pkg/github/code_quality_test.go @@ -0,0 +1,155 @@ +package github + +import ( + "context" + "encoding/json" + "net/http" + "testing" + + "github.com/google/go-github/v87/github" + "github.com/google/jsonschema-go/jsonschema" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/github/github-mcp-server/internal/toolsnaps" + "github.com/github/github-mcp-server/pkg/translations" +) + +func Test_GetCodeQualityFinding(t *testing.T) { + // Verify tool definition once + toolDef := GetCodeQualityFinding(translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(toolDef.Tool.Name, toolDef.Tool)) + + assert.Equal(t, "get_code_quality_finding", toolDef.Tool.Name) + assert.NotEmpty(t, toolDef.Tool.Description) + + // InputSchema is of type any, need to cast to *jsonschema.Schema + schema, ok := toolDef.Tool.InputSchema.(*jsonschema.Schema) + require.True(t, ok, "InputSchema should be *jsonschema.Schema") + assert.Contains(t, schema.Properties, "owner") + assert.Contains(t, schema.Properties, "repo") + assert.Contains(t, schema.Properties, "findingNumber") + assert.ElementsMatch(t, schema.Required, []string{"owner", "repo", "findingNumber"}) + + type codeQualityRule struct { + ID *string `json:"id,omitempty"` + Title *string `json:"title,omitempty"` + Description *string `json:"description,omitempty"` + Help *string `json:"help,omitempty"` + Severity *string `json:"severity,omitempty"` + Category *string `json:"category,omitempty"` + } + + type codeQualityLocation struct { + Path *string `json:"path,omitempty"` + StartLine *int `json:"start_line,omitempty"` + StartColumn *int `json:"start_column,omitempty"` + EndLine *int `json:"end_line,omitempty"` + EndColumn *int `json:"end_column,omitempty"` + } + + type codeQualityMessage struct { + Text string `json:"text"` + Markdown string `json:"markdown"` + } + + type codeQualityFinding struct { + Number *int `json:"number,omitempty"` + State *string `json:"state,omitempty"` + URL *string `json:"url,omitempty"` + Rule *codeQualityRule `json:"rule,omitempty"` + Location *codeQualityLocation `json:"location,omitempty"` + Message *codeQualityMessage `json:"message,omitempty"` + CreatedAt *github.Timestamp `json:"created_at,omitempty"` + } + + // Setup mock finding for success case + mockFinding := &codeQualityFinding{ + Number: github.Ptr(42), + State: github.Ptr("open"), + Rule: &codeQualityRule{ + ID: github.Ptr("test-rule"), + Description: github.Ptr("Test Rule Description"), + }, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + expectError bool + expectedFinding *codeQualityFinding + expectedErrMsg string + }{ + { + name: "successful finding fetch", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposCodeQualityFindingsByOwnerByRepoByFindingNumber: mockResponse(t, http.StatusOK, mockFinding), + }), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "findingNumber": float64(42), + }, + expectError: false, + expectedFinding: mockFinding, + }, + { + name: "finding fetch fails", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposCodeQualityFindingsByOwnerByRepoByFindingNumber: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + }, + }), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "findingNumber": float64(9999), + }, + expectError: true, + expectedErrMsg: "failed to get finding", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := mustNewGHClient(t, tc.mockedClient) + deps := BaseDeps{ + Client: client, + } + handler := toolDef.Handler(deps) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler with new signature + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + // Verify results + if tc.expectError { + require.NoError(t, err) + require.True(t, result.IsError) + errorContent := getErrorResult(t, result) + assert.Contains(t, errorContent.Text, tc.expectedErrMsg) + return + } + + require.NoError(t, err) + require.False(t, result.IsError) + + // Parse the result and get the text content if no error + textContent := getTextResult(t, result) + + // Unmarshal and verify the result + var returnedFinding codeQualityFinding + err = json.Unmarshal([]byte(textContent.Text), &returnedFinding) + assert.NoError(t, err) + assert.Equal(t, *tc.expectedFinding.Number, *returnedFinding.Number) + assert.Equal(t, *tc.expectedFinding.State, *returnedFinding.State) + assert.Equal(t, *tc.expectedFinding.Rule.ID, *returnedFinding.Rule.ID) + + }) + } +} diff --git a/pkg/github/discussions.go b/pkg/github/discussions.go index 1f94597739..68ed014b2b 100644 --- a/pkg/github/discussions.go +++ b/pkg/github/discussions.go @@ -276,7 +276,7 @@ func ListDiscussions(t translations.TranslationHelperFunc) inventory.ServerTool result := utils.NewToolResultText(string(out)) // Discussion content is user-authored (untrusted); confidentiality // follows repo visibility. - result = attachRepoVisibilityIFCLabelLazy(ctx, deps, owner, repo, result, ifc.LabelListIssues) + result = attachRepoVisibilityIFCLabelLazy(ctx, deps, owner, repo, result, ifc.LabelRepoUserContent) return result, nil, nil }, ) @@ -384,7 +384,7 @@ func GetDiscussion(t translations.TranslationHelperFunc) inventory.ServerTool { result := utils.NewToolResultText(string(out)) // Discussion content is user-authored (untrusted); confidentiality // follows repo visibility. - result = attachRepoVisibilityIFCLabelLazy(ctx, deps, params.Owner, params.Repo, result, ifc.LabelListIssues) + result = attachRepoVisibilityIFCLabelLazy(ctx, deps, params.Owner, params.Repo, result, ifc.LabelRepoUserContent) return result, nil, nil }, ) @@ -592,7 +592,7 @@ func GetDiscussionComments(t translations.TranslationHelperFunc) inventory.Serve result := utils.NewToolResultText(string(out)) // Discussion comments are user-authored (untrusted); confidentiality // follows repo visibility. - result = attachRepoVisibilityIFCLabelLazy(ctx, deps, params.Owner, params.Repo, result, ifc.LabelListIssues) + result = attachRepoVisibilityIFCLabelLazy(ctx, deps, params.Owner, params.Repo, result, ifc.LabelRepoUserContent) return result, nil, nil }, ) diff --git a/pkg/github/gists.go b/pkg/github/gists.go index 2eacabe4bc..9c319176bc 100644 --- a/pkg/github/gists.go +++ b/pkg/github/gists.go @@ -101,13 +101,7 @@ func ListGists(t translations.TranslationHelperFunc) inventory.ServerTool { } result := utils.NewToolResultText(string(r)) - // Gist contents are user-authored (untrusted); confidentiality is - // the IFC join of each gist's own public/secret flag. - visibilities := make([]bool, 0, len(gists)) - for _, g := range gists { - visibilities = append(visibilities, g.GetPublic()) - } - result = attachJoinedIFCLabel(ctx, deps, result, visibilities, ifc.LabelGistList) + result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelGistList()) return result, nil, nil }, ) @@ -167,9 +161,7 @@ func GetGist(t translations.TranslationHelperFunc) inventory.ServerTool { } result := utils.NewToolResultText(string(r)) - // Gist contents are user-authored (untrusted); confidentiality - // derives from the gist's own public/secret flag. - result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelGist(gist.GetPublic())) + result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelGist()) return result, nil, nil }, ) diff --git a/pkg/github/granular_tools_test.go b/pkg/github/granular_tools_test.go index 27e8079f97..4a274ac318 100644 --- a/pkg/github/granular_tools_test.go +++ b/pkg/github/granular_tools_test.go @@ -476,12 +476,12 @@ func TestGranularUpdateIssueLabelsConfidence(t *testing.T) { "repo": "repo", "issue_number": float64(1), "labels": []any{ - map[string]any{"name": "bug", "confidence": "high"}, + map[string]any{"name": "bug", "confidence": "HIGH"}, }, }, expectedReq: map[string]any{ "labels": []any{ - map[string]any{"name": "bug", "confidence": "high"}, + map[string]any{"name": "bug", "confidence": "HIGH"}, }, }, }, @@ -492,12 +492,28 @@ func TestGranularUpdateIssueLabelsConfidence(t *testing.T) { "repo": "repo", "issue_number": float64(1), "labels": []any{ - map[string]any{"name": "bug", "rationale": "Reports a crash", "confidence": "medium"}, + map[string]any{"name": "bug", "rationale": "Reports a crash", "confidence": "MEDIUM"}, }, }, expectedReq: map[string]any{ "labels": []any{ - map[string]any{"name": "bug", "rationale": "Reports a crash", "confidence": "medium"}, + map[string]any{"name": "bug", "rationale": "Reports a crash", "confidence": "MEDIUM"}, + }, + }, + }, + { + name: "label confidence is normalized", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(1), + "labels": []any{ + map[string]any{"name": "bug", "confidence": " high\t"}, + }, + }, + expectedReq: map[string]any{ + "labels": []any{ + map[string]any{"name": "bug", "confidence": "HIGH"}, }, }, }, @@ -528,7 +544,7 @@ func TestGranularUpdateIssueLabelsConfidence(t *testing.T) { require.NoError(t, err) errorContent := getErrorResult(t, result) - assert.Contains(t, errorContent.Text, "confidence must be one of: low, medium, high") + assert.Contains(t, errorContent.Text, "confidence must be one of: LOW, MEDIUM, HIGH") return } @@ -742,12 +758,12 @@ func TestGranularUpdateIssueTypeConfidence(t *testing.T) { "repo": "repo", "issue_number": float64(1), "issue_type": "bug", - "confidence": "high", + "confidence": "HIGH", }, expectedReq: map[string]any{ "type": map[string]any{ "value": "bug", - "confidence": "high", + "confidence": "HIGH", }, }, }, @@ -759,13 +775,13 @@ func TestGranularUpdateIssueTypeConfidence(t *testing.T) { "issue_number": float64(1), "issue_type": "feature", "rationale": "Asks for dark mode support", - "confidence": "medium", + "confidence": "MEDIUM", }, expectedReq: map[string]any{ "type": map[string]any{ "value": "feature", "rationale": "Asks for dark mode support", - "confidence": "medium", + "confidence": "MEDIUM", }, }, }, @@ -776,12 +792,28 @@ func TestGranularUpdateIssueTypeConfidence(t *testing.T) { "repo": "repo", "issue_number": float64(1), "issue_type": "bug", - "confidence": "low", + "confidence": "LOW", + }, + expectedReq: map[string]any{ + "type": map[string]any{ + "value": "bug", + "confidence": "LOW", + }, + }, + }, + { + name: "type confidence is normalized", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(1), + "issue_type": "bug", + "confidence": " medium ", }, expectedReq: map[string]any{ "type": map[string]any{ "value": "bug", - "confidence": "low", + "confidence": "MEDIUM", }, }, }, @@ -820,7 +852,7 @@ func TestGranularUpdateIssueTypeInvalidConfidence(t *testing.T) { "issue_type": "bug", "confidence": "very_high", }, - expectedErrText: "confidence must be one of: low, medium, high", + expectedErrText: "confidence must be one of: LOW, MEDIUM, HIGH", }, { name: "confidence wrong type", @@ -1599,7 +1631,7 @@ func TestGranularSetIssueFields(t *testing.T) { }) t.Run("successful set with confidence", func(t *testing.T) { - confidence := "high" + confidence := "HIGH" matchers := []githubv4mock.Matcher{ githubv4mock.NewQueryMatcher( struct { @@ -1680,7 +1712,7 @@ func TestGranularSetIssueFields(t *testing.T) { map[string]any{ "field_id": "FIELD_1", "text_value": "hello", - "confidence": "high", + "confidence": " high ", }, }, }) @@ -1709,11 +1741,11 @@ func TestGranularSetIssueFields(t *testing.T) { result, err := handler(ContextWithDeps(context.Background(), deps), &request) require.NoError(t, err) textContent := getTextResult(t, result) - assert.Contains(t, textContent.Text, "confidence must be one of: low, medium, high") + assert.Contains(t, textContent.Text, "confidence must be one of: LOW, MEDIUM, HIGH") }) t.Run("confidence is sent when supplied", func(t *testing.T) { - confidence := "high" + confidence := "HIGH" matchers := []githubv4mock.Matcher{ githubv4mock.NewQueryMatcher( struct { @@ -1794,7 +1826,7 @@ func TestGranularSetIssueFields(t *testing.T) { map[string]any{ "field_id": "FIELD_1", "text_value": "hello", - "confidence": "high", + "confidence": "HIGH", }, }, }) diff --git a/pkg/github/helper_test.go b/pkg/github/helper_test.go index 7f86c8b989..2ad1736794 100644 --- a/pkg/github/helper_test.go +++ b/pkg/github/helper_test.go @@ -22,6 +22,7 @@ import ( const ( // User endpoints GetUser = "GET /user" + GetUsersByUsername = "GET /users/{username}" GetUserStarred = "GET /user/starred" GetUsersGistsByUsername = "GET /users/{username}/gists" GetUsersStarredByUsername = "GET /users/{username}/starred" @@ -101,6 +102,9 @@ const ( GetReposReleasesLatestByOwnerByRepo = "GET /repos/{owner}/{repo}/releases/latest" GetReposReleasesTagsByOwnerByRepoByTag = "GET /repos/{owner}/{repo}/releases/tags/{tag}" + // Code quality endpoints + GetReposCodeQualityFindingsByOwnerByRepoByFindingNumber = "GET /repos/{owner}/{repo}/code-quality/findings/{finding_number}" + // Code scanning endpoints GetReposCodeScanningAlertsByOwnerByRepo = "GET /repos/{owner}/{repo}/code-scanning/alerts" GetReposCodeScanningAlertsByOwnerByRepoByAlertNumber = "GET /repos/{owner}/{repo}/code-scanning/alerts/{alert_number}" diff --git a/pkg/github/ifc_labels.go b/pkg/github/ifc_labels.go index a1c6fea367..9ab46b5136 100644 --- a/pkg/github/ifc_labels.go +++ b/pkg/github/ifc_labels.go @@ -17,6 +17,10 @@ func setIFCLabel(r *mcp.CallToolResult, label ifc.SecurityLabel) { r.Meta["ifc"] = label } +func shouldAttachIFCLabel(ctx context.Context, deps ToolDependencies, r *mcp.CallToolResult) bool { + return r != nil && !r.IsError && deps.IsFeatureEnabled(ctx, FeatureFlagIFCLabels) +} + // attachStaticIFCLabel attaches a fixed IFC label to a successful tool result // when IFC labels are enabled. It is used by tools whose label does not depend // on any repository visibility lookup (e.g. security alerts, global @@ -25,7 +29,7 @@ func setIFCLabel(r *mcp.CallToolResult, label ifc.SecurityLabel) { // Error results are left untouched, and the label is omitted entirely when the // IFC feature flag is disabled. func attachStaticIFCLabel(ctx context.Context, deps ToolDependencies, r *mcp.CallToolResult, label ifc.SecurityLabel) *mcp.CallToolResult { - if r == nil || r.IsError || !deps.IsFeatureEnabled(ctx, FeatureFlagIFCLabels) { + if !shouldAttachIFCLabel(ctx, deps, r) { return r } setIFCLabel(r, label) @@ -49,7 +53,7 @@ func attachRepoVisibilityIFCLabel( r *mcp.CallToolResult, labelFn func(isPrivate bool) ifc.SecurityLabel, ) *mcp.CallToolResult { - if r == nil || r.IsError || !deps.IsFeatureEnabled(ctx, FeatureFlagIFCLabels) { + if !shouldAttachIFCLabel(ctx, deps, r) { return r } isPrivate, err := FetchRepoIsPrivate(ctx, client, owner, repo) @@ -86,7 +90,7 @@ func attachRepoVisibilityIFCLabelLazy( r *mcp.CallToolResult, labelFn func(isPrivate bool) ifc.SecurityLabel, ) *mcp.CallToolResult { - if r == nil || r.IsError || !deps.IsFeatureEnabled(ctx, FeatureFlagIFCLabels) { + if !shouldAttachIFCLabel(ctx, deps, r) { return r } client, err := deps.GetClient(ctx) @@ -97,12 +101,11 @@ func attachRepoVisibilityIFCLabelLazy( } // attachJoinedIFCLabel attaches an IFC label computed by joining a set of -// per-item visibilities (true == private for repositories, true == public for -// gists) when IFC labels are enabled. joinFn is the lattice join for the -// relevant item kind (e.g. ifc.LabelSearchIssues or ifc.LabelGistList). The -// visibility slice is cheap to build from an already-fetched response, so -// callers may construct it unconditionally and let this helper own the -// feature-flag gate. +// per-item visibilities (true == private) when IFC labels are enabled. joinFn +// is the lattice join for the relevant item kind (e.g. ifc.LabelSearchIssues or +// ifc.LabelProjectList). The visibility slice is cheap to build from an +// already-fetched response, so callers may construct it unconditionally and let +// this helper own the feature-flag gate. func attachJoinedIFCLabel( ctx context.Context, deps ToolDependencies, @@ -110,13 +113,27 @@ func attachJoinedIFCLabel( visibilities []bool, joinFn func([]bool) ifc.SecurityLabel, ) *mcp.CallToolResult { - if r == nil || r.IsError || !deps.IsFeatureEnabled(ctx, FeatureFlagIFCLabels) { + if !shouldAttachIFCLabel(ctx, deps, r) { return r } setIFCLabel(r, joinFn(visibilities)) return r } +func attachProjectVisibilityIFCLabel( + ctx context.Context, + deps ToolDependencies, + r *mcp.CallToolResult, + isPrivate bool, + labelFn func(isPrivate bool) ifc.SecurityLabel, +) *mcp.CallToolResult { + if !shouldAttachIFCLabel(ctx, deps, r) { + return r + } + setIFCLabel(r, labelFn(isPrivate)) + return r +} + // newRepoVisibilityIFCLabeler returns a closure that attaches a repo-visibility // IFC label to a tool result, for handlers that have several return paths and // want to label each one. The returned function owns the feature-flag gate (so diff --git a/pkg/github/issues.go b/pkg/github/issues.go index 27fc0a4abe..f98982f1e6 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -804,7 +804,7 @@ Options are: // attachIFC adds the IFC label to a successful tool result when // IFC labels are enabled. If the visibility lookup fails the // label is omitted rather than misclassifying the result. - attachIFC := newRepoVisibilityIFCLabeler(ctx, deps, client, owner, repo, ifc.LabelListIssues) + attachIFC := newRepoVisibilityIFCLabeler(ctx, deps, client, owner, repo, ifc.LabelRepoUserContent) switch method { case "get": @@ -1067,13 +1067,14 @@ func GetIssueLabels(ctx context.Context, client *githubv4.Client, owner string, return utils.NewToolResultText(string(out)), nil } -// ListIssueTypes creates a tool to list defined issue types for an organization. This can be used to understand supported issue type values for creating or updating issues. +// ListIssueTypes creates a tool to list defined issue types for an organization or repository. +// This can be used to understand supported issue type values for creating or updating issues. func ListIssueTypes(t translations.TranslationHelperFunc) inventory.ServerTool { return NewTool( ToolsetMetadataIssues, mcp.Tool{ Name: "list_issue_types", - Description: t("TOOL_LIST_ISSUE_TYPES_FOR_ORG", "List supported issue types for repository owner (organization)."), + Description: t("TOOL_LIST_ISSUE_TYPES_FOR_ORG", "List supported issue types for a repository or its owner organization. When repo is omitted, returns org-level issue types directly."), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_LIST_ISSUE_TYPES_USER_TITLE", "List available issue types"), ReadOnlyHint: true, @@ -1083,23 +1084,63 @@ func ListIssueTypes(t translations.TranslationHelperFunc) inventory.ServerTool { Properties: map[string]*jsonschema.Schema{ "owner": { Type: "string", - Description: "The organization owner of the repository", + Description: "The account owner of the repository or organization.", + }, + "repo": { + Type: "string", + Description: "The name of the repository. When provided, returns issue types for this specific repository. When omitted, returns org-level issue types directly.", }, }, Required: []string{"owner"}, }, }, - []scopes.Scope{scopes.ReadOrg}, + []scopes.Scope{scopes.Repo, scopes.ReadOrg}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } + repo, err := OptionalParam[string](args, "repo") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } client, err := deps.GetClient(ctx) if err != nil { return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil } + + if repo != "" { + apiURL := fmt.Sprintf("repos/%s/%s/issue-types", owner, repo) + req, err := client.NewRequest(ctx, "GET", apiURL, nil) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to create request", err), nil, nil + } + var issueTypes []*github.IssueType + resp, err := client.Do(req, &issueTypes) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list issue types", resp, err), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, err := io.ReadAll(resp.Body) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to read response body", err), nil, nil + } + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to list issue types", resp, body), nil, nil + } + + r, err := json.Marshal(issueTypes) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to marshal issue types", err), nil, nil + } + + result := utils.NewToolResultText(string(r)) + result = attachRepoVisibilityIFCLabelLazy(ctx, deps, owner, repo, result, ifc.LabelRepoMetadata) + return result, nil, nil + } + issueTypes, resp, err := client.Organizations.ListIssueTypes(ctx, owner) if err != nil { return utils.NewToolResultErrorFromErr("failed to list issue types", err), nil, nil @@ -1753,8 +1794,7 @@ func searchIssuesHandler(ctx context.Context, deps ToolDependencies, args map[st const IssueWriteUIResourceURI = "ui://github-mcp-server/issue-write" // issueWriteFormParams are the parameters the issue_write MCP App form collects -// and re-sends on submit. The form only supports title/body editing (plus the -// routing/identity fields), so any other parameter present on a call cannot be +// and re-sends on submit. Any other parameter present on a call cannot be // represented by the form. var issueWriteFormParams = map[string]struct{}{ "method": {}, @@ -1763,12 +1803,17 @@ var issueWriteFormParams = map[string]struct{}{ "title": {}, "body": {}, "issue_number": {}, + "issue_fields": {}, + "state": {}, + "state_reason": {}, + "duplicate_of": {}, + "show_ui": {}, "_ui_submitted": {}, } // issueWriteHasNonFormParams reports whether the call carries any parameter the // issue_write MCP App form cannot represent (anything outside issueWriteFormParams, -// e.g. labels, assignees, issue_fields or a state change). Such calls must bypass +// e.g. labels, assignees, milestones or issue types). Such calls must bypass // the UI form and execute directly so the supplied values aren't silently dropped. func issueWriteHasNonFormParams(args map[string]any) bool { for key, value := range args { @@ -1782,6 +1827,36 @@ func issueWriteHasNonFormParams(args map[string]any) bool { return false } +// issueWriteAwaitingFormResult builds the "awaiting form submission" stub +// returned when issue_write hands off to the MCP App form. The body is shared +// by IssueWrite and LegacyIssueWrite. The result is marked IsError=true so +// agents that bail on error don't claim success or chain dependent tool calls +// while the user is still interacting with the form; the host renders the UI +// regardless because rendering is keyed off the tool's _meta.ui resourceUri. +func issueWriteAwaitingFormResult(method, owner, repo string, issueNumber int) *mcp.CallToolResult { + var msg string + if method == "update" { + msg = fmt.Sprintf( + "An interactive form has been shown to the user for editing issue #%d in %s/%s. "+ + "STOP — do not call any other tools, do not respond as if the issue was updated, "+ + "and do not claim the operation succeeded. The issue has NOT been updated yet; "+ + "only the form was rendered. Wait silently for the user to review and click Submit. "+ + "When they do, the real result will be delivered to your context automatically.", + issueNumber, owner, repo, + ) + } else { + msg = fmt.Sprintf( + "An interactive form has been shown to the user for creating a new issue in %s/%s. "+ + "STOP — do not call any other tools, do not respond as if the issue was created, "+ + "and do not claim the operation succeeded. The issue has NOT been created yet; "+ + "only the form was rendered. Wait silently for the user to review and click Submit. "+ + "When they do, the real result will be delivered to your context automatically.", + owner, repo, + ) + } + return utils.NewToolResultAwaitingFormSubmission(msg) +} + // IssueWrite is the FeatureFlagIssueFields-enabled variant of issue_write // (with the issue_fields parameter). LegacyIssueWrite is served when the flag // is off. Both register under the tool name "issue_write"; exactly one is @@ -1856,7 +1931,7 @@ Options are: }, "type": { Type: "string", - Description: "Type of this issue. Only use if the repository has issue types configured. Use list_issue_types tool to get valid type values for the organization. If the repository doesn't support issue types, omit this parameter.", + Description: "Type of this issue. Only use if issue types are enabled for this repository. Use list_issue_types tool to get valid type values for this repository or its owner organization. If the repository doesn't support issue types, omit this parameter.", }, "state": { Type: "string", @@ -1907,6 +1982,17 @@ Options are: Required: []string{"field_name"}, }, }, + // show_ui is hidden from clients that do not advertise MCP App + // UI support. The strip happens per-request in + // inventory.ToolsForRegistration; it is present in the static + // schema (and therefore in toolsnaps and the feature-flag / + // insiders docs) so the UI-capable surface is fully + // documented. It is intentionally not in the main README, + // which renders the stripped (non-UI) schema. + "show_ui": { + Type: "boolean", + Description: "Whether to render the MCP App form instead of executing the request immediately. Defaults to true. Set to false to skip the form and execute directly — useful when you have all required values (especially ones the form does not collect, like labels, assignees, milestone, type, issue_fields, or state changes) and the user has already confirmed the action.", + }, }, Required: []string{"method", "owner", "repo"}, }, @@ -1928,21 +2014,28 @@ Options are: } // When MCP Apps are enabled and the client supports UI, route the - // call to the interactive form unless it is itself a form submission - // (the UI sends _ui_submitted=true) or it carries parameters the form - // cannot represent (e.g. labels, assignees or issue_fields). Those - // must be applied directly so their values aren't silently dropped. + // call to the interactive form unless: + // - it is itself a form submission (the UI sends _ui_submitted=true), + // - the caller explicitly asked to skip the UI (show_ui=false), or + // - it carries parameters the form cannot represent (e.g. labels, + // assignees or issue_fields). Those must be applied directly so + // their values aren't silently dropped. uiSubmitted, _ := OptionalParam[bool](args, "_ui_submitted") + showUI, err := OptionalBoolParamWithDefault(args, "show_ui", true) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } - if deps.IsFeatureEnabled(ctx, MCPAppsFeatureFlag) && clientSupportsUI(ctx, req) && !uiSubmitted && !issueWriteHasNonFormParams(args) { + if deps.IsFeatureEnabled(ctx, MCPAppsFeatureFlag) && clientSupportsUI(ctx, req) && !uiSubmitted && showUI && !issueWriteHasNonFormParams(args) { + issueNumber := 0 if method == "update" { - issueNumber, numErr := RequiredInt(args, "issue_number") + n, numErr := RequiredInt(args, "issue_number") if numErr != nil { return utils.NewToolResultError("issue_number is required for update method"), nil, nil } - return utils.NewToolResultText(fmt.Sprintf("Ready to update issue #%d in %s/%s. IMPORTANT: The issue has NOT been updated yet. Do NOT tell the user the issue was updated. The user MUST click Submit in the form to update it.", issueNumber, owner, repo)), nil, nil + issueNumber = n } - return utils.NewToolResultText(fmt.Sprintf("Ready to create an issue in %s/%s. IMPORTANT: The issue has NOT been created yet. Do NOT tell the user the issue was created. The user MUST click Submit in the form to create it.", owner, repo)), nil, nil + return issueWriteAwaitingFormResult(method, owner, repo, issueNumber), nil, nil } title, err := OptionalParam[string](args, "title") @@ -2130,7 +2223,7 @@ Options are: }, "type": { Type: "string", - Description: "Type of this issue. Only use if the repository has issue types configured. Use list_issue_types tool to get valid type values for the organization. If the repository doesn't support issue types, omit this parameter.", + Description: "Type of this issue. Only use if issue types are enabled for this repository. Use list_issue_types tool to get valid type values for this repository or its owner organization. If the repository doesn't support issue types, omit this parameter.", }, "state": { Type: "string", @@ -2146,6 +2239,17 @@ Options are: Type: "number", Description: "Issue number that this issue is a duplicate of. Only used when state_reason is 'duplicate'.", }, + // show_ui is hidden from clients that do not advertise MCP App + // UI support. The strip happens per-request in + // inventory.ToolsForRegistration; it is present in the static + // schema (and therefore in toolsnaps and the feature-flag / + // insiders docs) so the UI-capable surface is fully + // documented. It is intentionally not in the main README, + // which renders the stripped (non-UI) schema. + "show_ui": { + Type: "boolean", + Description: "Whether to render the MCP App form instead of executing the request immediately. Defaults to true. Set to false to skip the form and execute directly — useful when you have all required values (especially ones the form does not collect, like labels, assignees, milestone, type, or state changes) and the user has already confirmed the action.", + }, }, Required: []string{"method", "owner", "repo"}, }, @@ -2167,21 +2271,28 @@ Options are: } // When MCP Apps are enabled and the client supports UI, route the - // call to the interactive form unless it is itself a form submission - // (the UI sends _ui_submitted=true) or it carries parameters the form - // cannot represent (e.g. labels, assignees or issue_fields). Those - // must be applied directly so their values aren't silently dropped. + // call to the interactive form unless: + // - it is itself a form submission (the UI sends _ui_submitted=true), + // - the caller explicitly asked to skip the UI (show_ui=false), or + // - it carries parameters the form cannot represent (e.g. labels, + // assignees or issue_fields). Those must be applied directly so + // their values aren't silently dropped. uiSubmitted, _ := OptionalParam[bool](args, "_ui_submitted") + showUI, err := OptionalBoolParamWithDefault(args, "show_ui", true) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } - if deps.IsFeatureEnabled(ctx, MCPAppsFeatureFlag) && clientSupportsUI(ctx, req) && !uiSubmitted && !issueWriteHasNonFormParams(args) { + if deps.IsFeatureEnabled(ctx, MCPAppsFeatureFlag) && clientSupportsUI(ctx, req) && !uiSubmitted && showUI && !issueWriteHasNonFormParams(args) { + issueNumber := 0 if method == "update" { - issueNumber, numErr := RequiredInt(args, "issue_number") + n, numErr := RequiredInt(args, "issue_number") if numErr != nil { return utils.NewToolResultError("issue_number is required for update method"), nil, nil } - return utils.NewToolResultText(fmt.Sprintf("Ready to update issue #%d in %s/%s. IMPORTANT: The issue has NOT been updated yet. Do NOT tell the user the issue was updated. The user MUST click Submit in the form to update it.", issueNumber, owner, repo)), nil, nil + issueNumber = n } - return utils.NewToolResultText(fmt.Sprintf("Ready to create an issue in %s/%s. IMPORTANT: The issue has NOT been created yet. Do NOT tell the user the issue was created. The user MUST click Submit in the form to create it.", owner, repo)), nil, nil + return issueWriteAwaitingFormResult(method, owner, repo, issueNumber), nil, nil } title, err := OptionalParam[string](args, "title") diff --git a/pkg/github/issues_granular.go b/pkg/github/issues_granular.go index 3ddfd682f6..157d5595fd 100644 --- a/pkg/github/issues_granular.go +++ b/pkg/github/issues_granular.go @@ -19,6 +19,10 @@ import ( "github.com/shurcooL/githubv4" ) +func normalizeConfidence(confidence string) string { + return strings.ToUpper(strings.TrimSpace(confidence)) +} + // issueUpdateTool is a helper to create single-field issue update tools. func issueUpdateTool( t translations.TranslationHelperFunc, @@ -281,7 +285,7 @@ func GranularUpdateIssueLabels(t translations.TranslationHelperFunc) inventory.S ToolsetMetadataIssues, mcp.Tool{ Name: "update_issue_labels", - Description: t("TOOL_UPDATE_ISSUE_LABELS_DESCRIPTION", "Update the labels of an existing issue. This replaces the current labels with the provided list. When setting values, include a confidence level (low, medium, or high) reflecting how certain you are about the choice."), + Description: t("TOOL_UPDATE_ISSUE_LABELS_DESCRIPTION", "Update the labels of an existing issue. This replaces the current labels with the provided list. When setting values, include a confidence level (LOW, MEDIUM, or HIGH) reflecting how certain you are about the choice."), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_UPDATE_ISSUE_LABELS_USER_TITLE", "Update Issue Labels"), ReadOnlyHint: false, @@ -325,8 +329,8 @@ func GranularUpdateIssueLabels(t translations.TranslationHelperFunc) inventory.S }, "confidence": { Type: "string", - Description: "How confident you are in this choice. Use '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"}, + Description: "How confident you are in this choice. Use '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", @@ -398,8 +402,9 @@ func GranularUpdateIssueLabels(t translations.TranslationHelperFunc) inventory.S if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } - if confidence != "" && confidence != "low" && confidence != "medium" && confidence != "high" { - return utils.NewToolResultError("confidence must be one of: low, medium, high"), nil, nil + confidence = normalizeConfidence(confidence) + if confidence != "" && confidence != "LOW" && confidence != "MEDIUM" && confidence != "HIGH" { + return utils.NewToolResultError("confidence must be one of: LOW, MEDIUM, HIGH"), nil, nil } isSuggestion, err := OptionalParam[bool](v, "is_suggestion") if err != nil { @@ -505,7 +510,7 @@ func GranularUpdateIssueType(t translations.TranslationHelperFunc) inventory.Ser ToolsetMetadataIssues, mcp.Tool{ Name: "update_issue_type", - Description: t("TOOL_UPDATE_ISSUE_TYPE_DESCRIPTION", "Update the type of an existing issue (e.g. 'bug', 'feature'). When setting values, include a confidence level (low, medium, or high) reflecting how certain you are about the choice."), + Description: t("TOOL_UPDATE_ISSUE_TYPE_DESCRIPTION", "Update the type of an existing issue (e.g. 'bug', 'feature'). When setting values, include a confidence level (LOW, MEDIUM, or HIGH) reflecting how certain you are about the choice."), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_UPDATE_ISSUE_TYPE_USER_TITLE", "Update Issue Type"), ReadOnlyHint: false, @@ -540,8 +545,8 @@ func GranularUpdateIssueType(t translations.TranslationHelperFunc) inventory.Ser }, "confidence": { Type: "string", - Description: "How confident you are in this choice. Use '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"}, + Description: "How confident you are in this choice. Use '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", @@ -582,8 +587,9 @@ func GranularUpdateIssueType(t translations.TranslationHelperFunc) inventory.Ser if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } - if confidence != "" && confidence != "low" && confidence != "medium" && confidence != "high" { - return utils.NewToolResultError("confidence must be one of: low, medium, high"), nil, nil + confidence = normalizeConfidence(confidence) + if confidence != "" && confidence != "LOW" && confidence != "MEDIUM" && confidence != "HIGH" { + return utils.NewToolResultError("confidence must be one of: LOW, MEDIUM, HIGH"), nil, nil } isSuggestion, err := OptionalParam[bool](args, "is_suggestion") if err != nil { @@ -987,8 +993,8 @@ func GranularSetIssueFields(t translations.TranslationHelperFunc) inventory.Serv }, "confidence": { Type: "string", - Description: "How confident you are in this choice. Use '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"}, + Description: "How confident you are in this choice. Use '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", @@ -1111,8 +1117,9 @@ func GranularSetIssueFields(t translations.TranslationHelperFunc) inventory.Serv if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } - if confidence != "" && confidence != "low" && confidence != "medium" && confidence != "high" { - return utils.NewToolResultError("confidence must be one of: low, medium, high"), nil, nil + confidence = normalizeConfidence(confidence) + if confidence != "" && confidence != "LOW" && confidence != "MEDIUM" && confidence != "HIGH" { + return utils.NewToolResultError("confidence must be one of: LOW, MEDIUM, HIGH"), nil, nil } if confidence != "" { input.Confidence = &confidence diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go index 7e47cdb527..5775daf377 100644 --- a/pkg/github/issues_test.go +++ b/pkg/github/issues_test.go @@ -15,6 +15,7 @@ import ( "github.com/github/github-mcp-server/internal/toolsnaps" "github.com/github/github-mcp-server/pkg/http/headers" transportpkg "github.com/github/github-mcp-server/pkg/http/transport" + "github.com/github/github-mcp-server/pkg/inventory" "github.com/github/github-mcp-server/pkg/translations" "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" @@ -355,7 +356,7 @@ func Test_IssueRead_IFC_InsidersMode(t *testing.T) { assert.Equal(t, "public", ifcMap["confidentiality"]) }) - t.Run("insiders mode enabled on private repo with get_comments emits private untrusted", func(t *testing.T) { + t.Run("insiders mode enabled on private repo with get_comments emits private trusted", func(t *testing.T) { deps := BaseDeps{ Client: mustNewGHClient(t, makeMockClient(true, 0)), featureChecker: featureCheckerFor(FeatureFlagIFCLabels), @@ -369,7 +370,7 @@ func Test_IssueRead_IFC_InsidersMode(t *testing.T) { require.NotNil(t, result.Meta) ifcMap := unmarshalIFC(t, result.Meta["ifc"]) - assert.Equal(t, "untrusted", ifcMap["integrity"]) + assert.Equal(t, "trusted", ifcMap["integrity"]) assert.Equal(t, "private", ifcMap["confidentiality"]) }) @@ -1561,7 +1562,8 @@ func Test_IssueWrite_MCPAppsFeature_UIGate(t *testing.T) { require.NoError(t, err) textContent := getTextResult(t, result) - assert.Contains(t, textContent.Text, "Ready to create an issue") + assert.Contains(t, textContent.Text, "interactive form has been shown to the user for creating a new issue") + assert.True(t, result.IsError, "form-routing stub should be marked IsError so agents don't claim success") }) t.Run("UI client with _ui_submitted executes directly", func(t *testing.T) { @@ -1595,78 +1597,10 @@ func Test_IssueWrite_MCPAppsFeature_UIGate(t *testing.T) { "non-UI client should execute directly") }) - t.Run("UI client with state change skips form and executes directly", func(t *testing.T) { - mockBaseIssue := &github.Issue{ - Number: github.Ptr(1), - Title: github.Ptr("Test"), - State: github.Ptr("open"), - HTMLURL: github.Ptr("https://github.com/owner/repo/issues/1"), - } - issueIDQueryResponse := githubv4mock.DataResponse(map[string]any{ - "repository": map[string]any{ - "issue": map[string]any{ - "id": "I_kwDOA0xdyM50BPaO", - }, - }, - }) - closeSuccessResponse := githubv4mock.DataResponse(map[string]any{ - "closeIssue": map[string]any{ - "issue": map[string]any{ - "id": "I_kwDOA0xdyM50BPaO", - "number": 1, - "url": "https://github.com/owner/repo/issues/1", - "state": "CLOSED", - }, - }, - }) - completedReason := IssueClosedStateReasonCompleted - - closeClient := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - PatchReposIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockBaseIssue), - })) - closeGQLClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient( - githubv4mock.NewQueryMatcher( - struct { - Repository struct { - Issue struct { - ID githubv4.ID - } `graphql:"issue(number: $issueNumber)"` - } `graphql:"repository(owner: $owner, name: $repo)"` - }{}, - map[string]any{ - "owner": githubv4.String("owner"), - "repo": githubv4.String("repo"), - "issueNumber": githubv4.Int(1), - }, - issueIDQueryResponse, - ), - githubv4mock.NewMutationMatcher( - struct { - CloseIssue struct { - Issue struct { - ID githubv4.ID - Number githubv4.Int - URL githubv4.String - State githubv4.String - } - } `graphql:"closeIssue(input: $input)"` - }{}, - CloseIssueInput{ - IssueID: "I_kwDOA0xdyM50BPaO", - StateReason: &completedReason, - }, - nil, - closeSuccessResponse, - ), - )) - - closeDeps := BaseDeps{ - Client: closeClient, - GQLClient: closeGQLClient, - featureChecker: featureCheckerFor(MCPAppsFeatureFlag), - } - closeHandler := serverTool.Handler(closeDeps) - + t.Run("UI client with state change routes through UI form", func(t *testing.T) { + // state/state_reason/duplicate_of are form params (the issue-write view + // renders close/reopen controls), so a call carrying them must go to + // the form rather than execute directly. request := createMCPRequestWithSession(t, ClientNameVSCodeInsiders, true, map[string]any{ "method": "update", "owner": "owner", @@ -1675,14 +1609,13 @@ func Test_IssueWrite_MCPAppsFeature_UIGate(t *testing.T) { "state": "closed", "state_reason": "completed", }) - result, err := closeHandler(ContextWithDeps(context.Background(), closeDeps), &request) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) require.NoError(t, err) textContent := getTextResult(t, result) - assert.NotContains(t, textContent.Text, "Ready to update issue", - "state change should skip UI form") - assert.Contains(t, textContent.Text, "https://github.com/owner/repo/issues/1", - "state change should execute directly and return issue URL") + assert.Contains(t, textContent.Text, "interactive form has been shown to the user for editing issue #1", + "state change should route through UI form") + assert.True(t, result.IsError, "form-routing stub should be marked IsError so agents don't claim success") }) t.Run("UI client update without state change returns form message", func(t *testing.T) { @@ -1697,65 +1630,15 @@ func Test_IssueWrite_MCPAppsFeature_UIGate(t *testing.T) { require.NoError(t, err) textContent := getTextResult(t, result) - assert.Contains(t, textContent.Text, "Ready to update issue #1", + assert.Contains(t, textContent.Text, "interactive form has been shown to the user for editing issue #1", "update without state should show UI form") + assert.True(t, result.IsError, "form-routing stub should be marked IsError so agents don't claim success") }) - t.Run("UI client with issue_fields skips form and executes directly", func(t *testing.T) { - // The MCP App form does not collect or re-send issue_fields, so a call - // carrying them must bypass the form and apply the values directly. - fieldsClient := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - PostReposIssuesByOwnerByRepo: expectRequestBody(t, map[string]any{ - "title": "Issue with fields", - "body": "", - "labels": []any{}, - "assignees": []any{}, - "issue_field_values": []any{ - map[string]any{"field_id": float64(101), "value": "P1"}, - }, - }).andThen( - mockResponse(t, http.StatusCreated, &github.Issue{ - Number: github.Ptr(125), - Title: github.Ptr("Issue with fields"), - HTMLURL: github.Ptr("https://github.com/owner/repo/issues/125"), - State: github.Ptr("open"), - }), - ), - })) - fieldsGQLClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient( - githubv4mock.NewQueryMatcher( - issueFieldWriteMetadataQuery{}, - map[string]any{ - "owner": githubv4.String("owner"), - "repo": githubv4.String("repo"), - }, - githubv4mock.DataResponse(map[string]any{ - "repository": map[string]any{ - "issueFields": map[string]any{ - "nodes": []any{ - map[string]any{ - "__typename": "IssueFieldSingleSelect", - "fullDatabaseId": "101", - "name": "Priority", - "dataType": "single_select", - "options": []any{ - map[string]any{"fullDatabaseId": "9001", "name": "P1"}, - }, - }, - }, - }, - }, - }), - ), - )) - - fieldsDeps := BaseDeps{ - Client: fieldsClient, - GQLClient: fieldsGQLClient, - featureChecker: featureCheckerFor(MCPAppsFeatureFlag), - } - fieldsHandler := serverTool.Handler(fieldsDeps) - + t.Run("UI client with issue_fields routes through UI form", func(t *testing.T) { + // issue_fields is now a form param (the issue-write view renders a + // per-field editor), so a call carrying it must go to the form rather + // than execute directly. request := createMCPRequestWithSession(t, ClientNameVSCodeInsiders, true, map[string]any{ "method": "create", "owner": "owner", @@ -1765,14 +1648,13 @@ func Test_IssueWrite_MCPAppsFeature_UIGate(t *testing.T) { map[string]any{"field_name": "Priority", "field_option_name": "P1"}, }, }) - result, err := fieldsHandler(ContextWithDeps(context.Background(), fieldsDeps), &request) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) require.NoError(t, err) textContent := getTextResult(t, result) - assert.NotContains(t, textContent.Text, "Ready to create an issue", - "issue_fields should skip UI form") - assert.Contains(t, textContent.Text, "https://github.com/owner/repo/issues/125", - "issue_fields call should execute directly and return issue URL") + assert.Contains(t, textContent.Text, "interactive form has been shown to the user for creating a new issue", + "issue_fields should route through UI form") + assert.True(t, result.IsError, "form-routing stub should be marked IsError so agents don't claim success") }) t.Run("UI client with labels skips form and executes directly", func(t *testing.T) { @@ -1789,11 +1671,91 @@ func Test_IssueWrite_MCPAppsFeature_UIGate(t *testing.T) { require.NoError(t, err) textContent := getTextResult(t, result) - assert.NotContains(t, textContent.Text, "Ready to create an issue", + assert.NotContains(t, textContent.Text, "interactive form has been shown", "labels should skip UI form") assert.Contains(t, textContent.Text, "https://github.com/owner/repo/issues/1", "labels call should execute directly and return issue URL") }) + + t.Run("UI client with show_ui=false skips form and executes directly", func(t *testing.T) { + // show_ui=false is the explicit, model-facing way to opt out of the + // form. It must bypass the form even when every other condition would + // route the call there (UI capability, MCP Apps flag on, no + // _ui_submitted, only form params present). + request := createMCPRequestWithSession(t, ClientNameVSCodeInsiders, true, map[string]any{ + "method": "create", + "owner": "owner", + "repo": "repo", + "title": "Test", + "show_ui": false, + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + + textContent := getTextResult(t, result) + assert.NotContains(t, textContent.Text, "interactive form has been shown", + "show_ui=false should skip UI form") + assert.Contains(t, textContent.Text, "https://github.com/owner/repo/issues/1", + "show_ui=false call should execute directly and return issue URL") + }) + + t.Run("UI client with show_ui=true returns form message", func(t *testing.T) { + // show_ui=true is the explicit, redundant-with-the-default way to ask + // for the form. It must still route through the form and must not be + // treated as a non-form parameter that would trigger the safety-net + // bypass. + request := createMCPRequestWithSession(t, ClientNameVSCodeInsiders, true, map[string]any{ + "method": "create", + "owner": "owner", + "repo": "repo", + "title": "Test", + "show_ui": true, + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, "interactive form has been shown", + "show_ui=true should still route through the form") + }) + + t.Run("UI client with show_ui=false and _ui_submitted=true executes directly", func(t *testing.T) { + // _ui_submitted and show_ui=false are two ways to say "execute + // directly". When both are set there must be no conflict — the call + // still executes directly. + request := createMCPRequestWithSession(t, ClientNameVSCodeInsiders, true, map[string]any{ + "method": "create", + "owner": "owner", + "repo": "repo", + "title": "Test", + "show_ui": false, + "_ui_submitted": true, + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, "https://github.com/owner/repo/issues/1", + "show_ui=false + _ui_submitted should execute directly") + }) + + t.Run("non-UI client with show_ui=false executes directly (no regression)", func(t *testing.T) { + // show_ui is irrelevant when the client does not support UI; the call + // must execute directly exactly as it does today. + request := createMCPRequest(map[string]any{ + "method": "create", + "owner": "owner", + "repo": "repo", + "title": "Test", + "show_ui": false, + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, "https://github.com/owner/repo/issues/1", + "non-UI client should execute directly regardless of show_ui") + }) } func Test_issueWriteHasNonFormParams(t *testing.T) { @@ -1806,14 +1768,16 @@ func Test_issueWriteHasNonFormParams(t *testing.T) { }{ {name: "no params", args: map[string]any{}, want: false}, {name: "only form params", args: map[string]any{"method": "create", "owner": "o", "repo": "r", "title": "t", "body": "b", "issue_number": float64(1), "_ui_submitted": true}, want: false}, + {name: "show_ui true is a form param", args: map[string]any{"title": "t", "show_ui": true}, want: false}, + {name: "show_ui false is a form param", args: map[string]any{"title": "t", "show_ui": false}, want: false}, {name: "labels present", args: map[string]any{"title": "t", "labels": []any{"bug"}}, want: true}, {name: "assignees present", args: map[string]any{"title": "t", "assignees": []any{"octocat"}}, want: true}, {name: "milestone present", args: map[string]any{"title": "t", "milestone": float64(2)}, want: true}, {name: "type present", args: map[string]any{"title": "t", "type": "Bug"}, want: true}, - {name: "issue_fields present", args: map[string]any{"issue_fields": []any{map[string]any{"field_name": "Priority"}}}, want: true}, - {name: "state present", args: map[string]any{"state": "closed"}, want: true}, - {name: "state_reason present", args: map[string]any{"state_reason": "completed"}, want: true}, - {name: "duplicate_of present", args: map[string]any{"duplicate_of": float64(7)}, want: true}, + {name: "issue_fields present", args: map[string]any{"issue_fields": []any{map[string]any{"field_name": "Priority"}}}, want: false}, + {name: "state present", args: map[string]any{"state": "closed"}, want: false}, + {name: "state_reason present", args: map[string]any{"state_reason": "completed"}, want: false}, + {name: "duplicate_of present", args: map[string]any{"duplicate_of": float64(7)}, want: false}, {name: "nil value is ignored", args: map[string]any{"issue_fields": nil}, want: false}, } @@ -1825,6 +1789,52 @@ func Test_issueWriteHasNonFormParams(t *testing.T) { } } +// Test_issueWriteSchemaClassification fails when a schema property is added +// without classifying it as either form-resendable (issueWriteFormParams) or +// known-non-form (knownNonForm below). Without this guard, an unclassified +// property would silently flip UI gating: form-incompatible fields would +// stop tripping the safety-net bypass and the form would drop their values. +func Test_issueWriteSchemaClassification(t *testing.T) { + t.Parallel() + + // Schema properties the MCP App form cannot represent — their presence + // must trigger the safety-net bypass via issueWriteHasNonFormParams. + knownNonForm := map[string]struct{}{ + "assignees": {}, + "labels": {}, + "milestone": {}, + "type": {}, + } + + cases := []struct { + name string + tool inventory.ServerTool + }{ + {name: "IssueWrite", tool: IssueWrite(translations.NullTranslationHelper)}, + {name: "LegacyIssueWrite", tool: LegacyIssueWrite(translations.NullTranslationHelper)}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + schema, ok := tc.tool.Tool.InputSchema.(*jsonschema.Schema) + require.True(t, ok, "InputSchema should be *jsonschema.Schema") + + for prop := range schema.Properties { + _, isForm := issueWriteFormParams[prop] + _, isNonForm := knownNonForm[prop] + + assert.Falsef(t, isForm && isNonForm, + "property %q is classified as both form-resendable and non-form — pick one", prop) + assert.Truef(t, isForm || isNonForm, + "property %q in %s schema is unclassified — add it to issueWriteFormParams (pkg/github/issues.go) "+ + "if the MCP App form can carry it on submit, otherwise add it to the knownNonForm allowlist in this test", + prop, tc.name) + } + }) + } +} + func Test_ListIssues(t *testing.T) { // Verify tool definition serverTool := ListIssues(translations.NullTranslationHelper) @@ -2719,7 +2729,7 @@ func Test_ListIssues_IFC_InsidersMode(t *testing.T) { assert.Equal(t, "public", ifcMap["confidentiality"]) }) - t.Run("insiders mode enabled on private repo emits private untrusted label", func(t *testing.T) { + t.Run("insiders mode enabled on private repo emits private trusted label", func(t *testing.T) { matcher := githubv4mock.NewQueryMatcher(query, vars, makeResponse(true)) gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(matcher)) deps := BaseDeps{ @@ -2742,7 +2752,7 @@ func Test_ListIssues_IFC_InsidersMode(t *testing.T) { var ifcMap map[string]any require.NoError(t, json.Unmarshal(ifcJSON, &ifcMap)) - assert.Equal(t, "untrusted", ifcMap["integrity"]) + assert.Equal(t, "trusted", ifcMap["integrity"]) assert.Equal(t, "private", ifcMap["confidentiality"]) }) } @@ -4802,6 +4812,30 @@ func Test_ListIssueTypes(t *testing.T) { expectError: false, // This should be handled by parameter validation, error returned in result expectedErrMsg: "missing required parameter: owner", }, + { + name: "successful repo issue types retrieval", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "GET /repos/testorg/testrepo/issue-types": mockResponse(t, http.StatusOK, mockIssueTypes), + }), + requestArgs: map[string]any{ + "owner": "testorg", + "repo": "testrepo", + }, + expectError: false, + expectedIssueTypes: mockIssueTypes, + }, + { + name: "repo not found", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "GET /repos/testorg/nonexistent/issue-types": mockResponse(t, http.StatusNotFound, `{"message": "Not Found"}`), + }), + requestArgs: map[string]any{ + "owner": "testorg", + "repo": "nonexistent", + }, + expectError: true, + expectedErrMsg: "failed to list issue types", + }, } for _, tc := range tests { diff --git a/pkg/github/projects.go b/pkg/github/projects.go index 85774490de..8f24cde7e2 100644 --- a/pkg/github/projects.go +++ b/pkg/github/projects.go @@ -65,33 +65,43 @@ type statusUpdateNode struct { } } +type projectVisibility struct { + Public githubv4.Boolean +} + +type statusUpdateNodeWithProject struct { + statusUpdateNode + Project projectVisibility +} + type statusUpdateConnection struct { Nodes []statusUpdateNode PageInfo PageInfoFragment } +type statusUpdatesProject struct { + Public githubv4.Boolean + StatusUpdates statusUpdateConnection `graphql:"statusUpdates(first: $first, after: $after, orderBy: {field: CREATED_AT, direction: DESC})"` +} + // statusUpdatesUserQuery is the GraphQL query for listing status updates on a user-owned project. type statusUpdatesUserQuery struct { User struct { - ProjectV2 struct { - StatusUpdates statusUpdateConnection `graphql:"statusUpdates(first: $first, after: $after, orderBy: {field: CREATED_AT, direction: DESC})"` - } `graphql:"projectV2(number: $projectNumber)"` + ProjectV2 statusUpdatesProject `graphql:"projectV2(number: $projectNumber)"` } `graphql:"user(login: $owner)"` } // statusUpdatesOrgQuery is the GraphQL query for listing status updates on an org-owned project. type statusUpdatesOrgQuery struct { Organization struct { - ProjectV2 struct { - StatusUpdates statusUpdateConnection `graphql:"statusUpdates(first: $first, after: $after, orderBy: {field: CREATED_AT, direction: DESC})"` - } `graphql:"projectV2(number: $projectNumber)"` + ProjectV2 statusUpdatesProject `graphql:"projectV2(number: $projectNumber)"` } `graphql:"organization(login: $owner)"` } // statusUpdateNodeQuery is the GraphQL query for fetching a single status update by node ID. type statusUpdateNodeQuery struct { Node struct { - StatusUpdate statusUpdateNode `graphql:"... on ProjectV2StatusUpdate"` + StatusUpdate statusUpdateNodeWithProject `graphql:"... on ProjectV2StatusUpdate"` } `graphql:"node(id: $id)"` } @@ -228,26 +238,18 @@ Use this tool to list projects for a user or organization, or list project field return utils.NewToolResultError(err.Error()), nil, nil } - // attachIFC adds the IFC label to a successful result when IFC - // labels are enabled. Project titles, item content, field - // definitions, and status updates are user-authored free text - // (untrusted); confidentiality is conservatively private since the - // project's public flag is not available across every sub-result. - attachIFC := func(r *mcp.CallToolResult) *mcp.CallToolResult { - return attachStaticIFCLabel(ctx, deps, r, ifc.LabelProject(false)) - } - switch method { case projectsMethodListProjects: - result, payload, err := listProjects(ctx, client, args, owner, ownerType) - return attachIFC(result), payload, err - default: + result, visibilities, payload, err := listProjects(ctx, client, args, owner, ownerType) + result = attachJoinedIFCLabel(ctx, deps, result, visibilities, ifc.LabelProjectList) + return result, payload, err + case projectsMethodListProjectFields, projectsMethodListProjectItems, projectsMethodListProjectStatusUpdates: // All other methods require project_number and ownerType detection + projectNumber, err := RequiredInt(args, "project_number") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } if ownerType == "" { - projectNumber, err := RequiredInt(args, "project_number") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } ownerType, err = detectOwnerType(ctx, client, owner, projectNumber) if err != nil { return utils.NewToolResultError(err.Error()), nil, nil @@ -257,20 +259,35 @@ Use this tool to list projects for a user or organization, or list project field switch method { case projectsMethodListProjectFields: result, payload, err := listProjectFields(ctx, client, args, owner, ownerType) - return attachIFC(result), payload, err + if shouldAttachIFCLabel(ctx, deps, result) { + isPrivate, visibilityErr := FetchProjectIsPrivate(ctx, client, owner, ownerType, projectNumber) + if visibilityErr == nil { + result = attachProjectVisibilityIFCLabel(ctx, deps, result, isPrivate, ifc.LabelProject) + } + } + return result, payload, err case projectsMethodListProjectItems: result, payload, err := listProjectItems(ctx, client, args, owner, ownerType) - return attachIFC(result), payload, err + if shouldAttachIFCLabel(ctx, deps, result) { + isPrivate, visibilityErr := FetchProjectIsPrivate(ctx, client, owner, ownerType, projectNumber) + if visibilityErr == nil { + result = attachProjectVisibilityIFCLabel(ctx, deps, result, isPrivate, ifc.LabelProjectContent) + } + } + return result, payload, err case projectsMethodListProjectStatusUpdates: gqlClient, err := deps.GetGQLClient(ctx) if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } - result, payload, err := listProjectStatusUpdates(ctx, gqlClient, args, owner, ownerType) - return attachIFC(result), payload, err + result, isPrivate, payload, err := listProjectStatusUpdates(ctx, gqlClient, args, owner, ownerType) + result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelProjectContent(isPrivate)) + return result, payload, err default: return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil } + default: + return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil } }, ) @@ -346,14 +363,6 @@ Use this tool to get details about individual projects, project fields, and proj return utils.NewToolResultError(err.Error()), nil, nil } - // attachIFC adds the IFC label to a successful result when IFC - // labels are enabled. Project data is user-authored free text - // (untrusted); confidentiality is conservatively private since the - // project's public flag is not available across every sub-result. - attachIFC := func(r *mcp.CallToolResult) *mcp.CallToolResult { - return attachStaticIFCLabel(ctx, deps, r, ifc.LabelProject(false)) - } - // Handle get_project_status_update early — it only needs status_update_id if method == projectsMethodGetProjectStatusUpdate { statusUpdateID, err := RequiredParam[string](args, "status_update_id") @@ -364,8 +373,9 @@ Use this tool to get details about individual projects, project fields, and proj if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } - result, payload, err := getProjectStatusUpdate(ctx, gqlClient, statusUpdateID) - return attachIFC(result), payload, err + result, isPrivate, payload, err := getProjectStatusUpdate(ctx, gqlClient, statusUpdateID) + result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelProjectContent(isPrivate)) + return result, payload, err } owner, err := RequiredParam[string](args, "owner") @@ -398,15 +408,22 @@ Use this tool to get details about individual projects, project fields, and proj switch method { case projectsMethodGetProject: - result, payload, err := getProject(ctx, client, owner, ownerType, projectNumber) - return attachIFC(result), payload, err + result, isPrivate, payload, err := getProject(ctx, client, owner, ownerType, projectNumber) + result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelProject(isPrivate)) + return result, payload, err case projectsMethodGetProjectField: fieldID, err := RequiredBigInt(args, "field_id") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } result, payload, err := getProjectField(ctx, client, owner, ownerType, projectNumber, fieldID) - return attachIFC(result), payload, err + if shouldAttachIFCLabel(ctx, deps, result) { + isPrivate, visibilityErr := FetchProjectIsPrivate(ctx, client, owner, ownerType, projectNumber) + if visibilityErr == nil { + result = attachProjectVisibilityIFCLabel(ctx, deps, result, isPrivate, ifc.LabelProject) + } + } + return result, payload, err case projectsMethodGetProjectItem: itemID, err := RequiredBigInt(args, "item_id") if err != nil { @@ -417,7 +434,13 @@ Use this tool to get details about individual projects, project fields, and proj return utils.NewToolResultError(err.Error()), nil, nil } result, payload, err := getProjectItem(ctx, client, owner, ownerType, projectNumber, itemID, fields) - return attachIFC(result), payload, err + if shouldAttachIFCLabel(ctx, deps, result) { + isPrivate, visibilityErr := FetchProjectIsPrivate(ctx, client, owner, ownerType, projectNumber) + if visibilityErr == nil { + result = attachProjectVisibilityIFCLabel(ctx, deps, result, isPrivate, ifc.LabelProjectContent) + } + } + return result, payload, err default: return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil } @@ -678,15 +701,15 @@ func ProjectsWrite(t translations.TranslationHelperFunc) inventory.ServerTool { // Helper functions for consolidated projects tools -func listProjects(ctx context.Context, client *github.Client, args map[string]any, owner, ownerType string) (*mcp.CallToolResult, any, error) { +func listProjects(ctx context.Context, client *github.Client, args map[string]any, owner, ownerType string) (*mcp.CallToolResult, []bool, any, error) { queryStr, err := OptionalParam[string](args, "query") if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + return utils.NewToolResultError(err.Error()), nil, nil, nil } pagination, err := extractPaginationOptionsFromArgs(args) if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + return utils.NewToolResultError(err.Error()), nil, nil, nil } var resp *github.Response @@ -709,7 +732,7 @@ func listProjects(ctx context.Context, client *github.Client, args map[string]an "failed to list projects", resp, err, - ), nil, nil + ), nil, nil, nil } default: projects, resp, err = client.Projects.ListUserProjects(ctx, owner, opts) @@ -718,7 +741,7 @@ func listProjects(ctx context.Context, client *github.Client, args map[string]an "failed to list projects", resp, err, - ), nil, nil + ), nil, nil, nil } } @@ -739,18 +762,18 @@ func listProjects(ctx context.Context, client *github.Client, args map[string]an r, err := json.Marshal(response) if err != nil { - return nil, nil, fmt.Errorf("failed to marshal response: %w", err) + return nil, nil, nil, fmt.Errorf("failed to marshal response: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil + return utils.NewToolResultText(string(r)), projectVisibilities(minimalProjects), nil, nil } - return nil, nil, fmt.Errorf("unexpected state in listProjects") + return nil, nil, nil, fmt.Errorf("unexpected state in listProjects") } // listProjectsFromBothOwnerTypes fetches projects from both user and org endpoints // when owner_type is not specified, combining the results with owner_type labels. -func listProjectsFromBothOwnerTypes(ctx context.Context, client *github.Client, owner string, opts *github.ListProjectsOptions) (*mcp.CallToolResult, any, error) { +func listProjectsFromBothOwnerTypes(ctx context.Context, client *github.Client, owner string, opts *github.ListProjectsOptions) (*mcp.CallToolResult, []bool, any, error) { var minimalProjects []MinimalProject var resp *github.Response @@ -781,7 +804,7 @@ func listProjectsFromBothOwnerTypes(ctx context.Context, client *github.Client, // If both failed, return error if (userErr != nil || userResp == nil || userResp.StatusCode != http.StatusOK) && (orgErr != nil || orgResp == nil || orgResp.StatusCode != http.StatusOK) { - return utils.NewToolResultError(fmt.Sprintf("failed to list projects for owner '%s': not found as user or organization", owner)), nil, nil + return utils.NewToolResultError(fmt.Sprintf("failed to list projects for owner '%s': not found as user or organization", owner)), nil, nil, nil } response := map[string]any{ @@ -795,9 +818,21 @@ func listProjectsFromBothOwnerTypes(ctx context.Context, client *github.Client, r, err := json.Marshal(response) if err != nil { - return nil, nil, fmt.Errorf("failed to marshal response: %w", err) + return nil, nil, nil, fmt.Errorf("failed to marshal response: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil + return utils.NewToolResultText(string(r)), projectVisibilities(minimalProjects), nil, nil +} + +func projectVisibilities(projects []MinimalProject) []bool { + visibilities := make([]bool, 0, len(projects)) + for _, project := range projects { + isPrivate := true + if project.Public != nil { + isPrivate = !*project.Public + } + visibilities = append(visibilities, isPrivate) + } + return visibilities } func listProjectFields(ctx context.Context, client *github.Client, args map[string]any, owner, ownerType string) (*mcp.CallToolResult, any, error) { @@ -911,40 +946,54 @@ func listProjectItems(ctx context.Context, client *github.Client, args map[strin return utils.NewToolResultText(string(r)), nil, nil } -func getProject(ctx context.Context, client *github.Client, owner, ownerType string, projectNumber int) (*mcp.CallToolResult, any, error) { - var resp *github.Response - var project *github.ProjectV2 - var err error - +func fetchProjectV2(ctx context.Context, client *github.Client, owner, ownerType string, projectNumber int) (*github.ProjectV2, *github.Response, error) { if ownerType == "org" { - project, resp, err = client.Projects.GetOrganizationProject(ctx, owner, projectNumber) - } else { - project, resp, err = client.Projects.GetUserProject(ctx, owner, projectNumber) + return client.Projects.GetOrganizationProject(ctx, owner, projectNumber) + } + return client.Projects.GetUserProject(ctx, owner, projectNumber) +} + +// FetchProjectIsPrivate returns whether a GitHub Project is private. +func FetchProjectIsPrivate(ctx context.Context, client *github.Client, owner, ownerType string, projectNumber int) (bool, error) { + project, resp, err := fetchProjectV2(ctx, client, owner, ownerType, projectNumber) + if resp != nil && resp.Body != nil { + defer func() { _ = resp.Body.Close() }() + } + if err != nil { + return false, err } + if resp == nil || resp.StatusCode != http.StatusOK { + return false, fmt.Errorf("failed to fetch project visibility") + } + return !project.GetPublic(), nil +} + +func getProject(ctx context.Context, client *github.Client, owner, ownerType string, projectNumber int) (*mcp.CallToolResult, bool, any, error) { + project, resp, err := fetchProjectV2(ctx, client, owner, ownerType, projectNumber) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get project", resp, err, - ), nil, nil + ), false, nil, nil } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { body, err := io.ReadAll(resp.Body) if err != nil { - return nil, nil, fmt.Errorf("failed to read response body: %w", err) + return nil, false, nil, fmt.Errorf("failed to read response body: %w", err) } - return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get project", resp, body), nil, nil + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get project", resp, body), false, nil, nil } minimalProject := convertToMinimalProject(project) r, err := json.Marshal(minimalProject) if err != nil { - return nil, nil, fmt.Errorf("failed to marshal response: %w", err) + return nil, false, nil, fmt.Errorf("failed to marshal response: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil + return utils.NewToolResultText(string(r)), !project.GetPublic(), nil, nil } func getProjectField(ctx context.Context, client *github.Client, owner, ownerType string, projectNumber int, fieldID int64) (*mcp.CallToolResult, any, error) { @@ -1274,19 +1323,19 @@ func createProjectStatusUpdate(ctx context.Context, gqlClient *githubv4.Client, } // listProjectStatusUpdates lists status updates for a project via GraphQL. -func listProjectStatusUpdates(ctx context.Context, gqlClient *githubv4.Client, args map[string]any, owner, ownerType string) (*mcp.CallToolResult, any, error) { +func listProjectStatusUpdates(ctx context.Context, gqlClient *githubv4.Client, args map[string]any, owner, ownerType string) (*mcp.CallToolResult, bool, any, error) { if ownerType != "user" && ownerType != "org" { - return utils.NewToolResultError(fmt.Sprintf("invalid owner_type %q: must be \"user\" or \"org\"", ownerType)), nil, nil + return utils.NewToolResultError(fmt.Sprintf("invalid owner_type %q: must be \"user\" or \"org\"", ownerType)), false, nil, nil } projectNumber, err := RequiredInt(args, "project_number") if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + return utils.NewToolResultError(err.Error()), false, nil, nil } perPage, err := OptionalIntParamWithDefault(args, "per_page", MaxProjectsPerPage) if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + return utils.NewToolResultError(err.Error()), false, nil, nil } if perPage > MaxProjectsPerPage { perPage = MaxProjectsPerPage @@ -1297,7 +1346,7 @@ func listProjectStatusUpdates(ctx context.Context, gqlClient *githubv4.Client, a afterCursor, err := OptionalParam[string](args, "after") if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + return utils.NewToolResultError(err.Error()), false, nil, nil } vars := map[string]any{ @@ -1313,21 +1362,26 @@ func listProjectStatusUpdates(ctx context.Context, gqlClient *githubv4.Client, a var nodes []statusUpdateNode var pi PageInfoFragment + var isPrivate bool if ownerType == "org" { var q statusUpdatesOrgQuery if err := gqlClient.Query(ctx, &q, vars); err != nil { - return utils.NewToolResultError(fmt.Sprintf("%s: %v", ProjectStatusUpdateListFailedError, err)), nil, nil + return utils.NewToolResultError(fmt.Sprintf("%s: %v", ProjectStatusUpdateListFailedError, err)), false, nil, nil } - nodes = q.Organization.ProjectV2.StatusUpdates.Nodes - pi = q.Organization.ProjectV2.StatusUpdates.PageInfo + project := q.Organization.ProjectV2 + nodes = project.StatusUpdates.Nodes + pi = project.StatusUpdates.PageInfo + isPrivate = !bool(project.Public) } else { var q statusUpdatesUserQuery if err := gqlClient.Query(ctx, &q, vars); err != nil { - return utils.NewToolResultError(fmt.Sprintf("%s: %v", ProjectStatusUpdateListFailedError, err)), nil, nil + return utils.NewToolResultError(fmt.Sprintf("%s: %v", ProjectStatusUpdateListFailedError, err)), false, nil, nil } - nodes = q.User.ProjectV2.StatusUpdates.Nodes - pi = q.User.ProjectV2.StatusUpdates.PageInfo + project := q.User.ProjectV2 + nodes = project.StatusUpdates.Nodes + pi = project.StatusUpdates.PageInfo + isPrivate = !bool(project.Public) } updates := make([]MinimalProjectStatusUpdate, 0, len(nodes)) @@ -1347,33 +1401,34 @@ func listProjectStatusUpdates(ctx context.Context, gqlClient *githubv4.Client, a r, err := json.Marshal(response) if err != nil { - return nil, nil, fmt.Errorf("failed to marshal response: %w", err) + return nil, false, nil, fmt.Errorf("failed to marshal response: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil + return utils.NewToolResultText(string(r)), isPrivate, nil, nil } // getProjectStatusUpdate fetches a single status update by its node ID via GraphQL. -func getProjectStatusUpdate(ctx context.Context, gqlClient *githubv4.Client, statusUpdateID string) (*mcp.CallToolResult, any, error) { +func getProjectStatusUpdate(ctx context.Context, gqlClient *githubv4.Client, statusUpdateID string) (*mcp.CallToolResult, bool, any, error) { var q statusUpdateNodeQuery vars := map[string]any{ "id": githubv4.ID(statusUpdateID), } if err := gqlClient.Query(ctx, &q, vars); err != nil { - return utils.NewToolResultError(fmt.Sprintf("%s: %v", ProjectStatusUpdateGetFailedError, err)), nil, nil + return utils.NewToolResultError(fmt.Sprintf("%s: %v", ProjectStatusUpdateGetFailedError, err)), false, nil, nil } if q.Node.StatusUpdate.ID == nil || q.Node.StatusUpdate.ID == "" { - return utils.NewToolResultError(fmt.Sprintf("%s: node is not a ProjectV2StatusUpdate or was not found", ProjectStatusUpdateGetFailedError)), nil, nil + return utils.NewToolResultError(fmt.Sprintf("%s: node is not a ProjectV2StatusUpdate or was not found", ProjectStatusUpdateGetFailedError)), false, nil, nil } - update := convertToMinimalStatusUpdate(q.Node.StatusUpdate) + update := convertToMinimalStatusUpdate(q.Node.StatusUpdate.statusUpdateNode) + isPrivate := !bool(q.Node.StatusUpdate.Project.Public) r, err := json.Marshal(update) if err != nil { - return nil, nil, fmt.Errorf("failed to marshal response: %w", err) + return nil, false, nil, fmt.Errorf("failed to marshal response: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil + return utils.NewToolResultText(string(r)), isPrivate, nil, nil } // validateAndConvertToInt64 ensures the value is a number and converts it to int64. @@ -1761,11 +1816,25 @@ type ProjectV2IterationFieldIterationInput struct { Title githubv4.String `json:"title"` } -// detectOwnerType attempts to detect the owner type by trying both user and org -// Returns the detected type ("user" or "org") and any error encountered +// detectOwnerType attempts to detect whether the project owner is a user or org. +// It first asks GitHub for the account type, then falls back to project probes +// for older or mocked clients where the account type is unavailable. func detectOwnerType(ctx context.Context, client *github.Client, owner string, projectNumber int) (string, error) { + user, resp, err := client.Users.Get(ctx, owner) + if resp != nil && resp.Body != nil { + _ = resp.Body.Close() + } + if err == nil && resp != nil && resp.StatusCode == http.StatusOK { + switch user.GetType() { + case "User": + return "user", nil + case "Organization": + return "org", nil + } + } + // Try user first (more common for personal projects) - _, resp, err := client.Projects.GetUserProject(ctx, owner, projectNumber) + _, resp, err = client.Projects.GetUserProject(ctx, owner, projectNumber) if err == nil && resp.StatusCode == http.StatusOK { _ = resp.Body.Close() return "user", nil diff --git a/pkg/github/projects_test.go b/pkg/github/projects_test.go index ad5ce6db86..05914975a0 100644 --- a/pkg/github/projects_test.go +++ b/pkg/github/projects_test.go @@ -366,6 +366,196 @@ func Test_ProjectsList_ListProjectItems(t *testing.T) { }) } +func Test_detectOwnerType(t *testing.T) { + t.Run("uses organization account type", func(t *testing.T) { + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetUsersByUsername: mockResponse(t, http.StatusOK, map[string]any{ + "login": "github", + "type": "Organization", + }), + }) + client := mustNewGHClient(t, mockedClient) + + ownerType, err := detectOwnerType(context.Background(), client, "github", 1) + + require.NoError(t, err) + assert.Equal(t, "org", ownerType) + }) + + t.Run("uses user account type", func(t *testing.T) { + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetUsersByUsername: mockResponse(t, http.StatusOK, map[string]any{ + "login": "octocat", + "type": "User", + }), + }) + client := mustNewGHClient(t, mockedClient) + + ownerType, err := detectOwnerType(context.Background(), client, "octocat", 1) + + require.NoError(t, err) + assert.Equal(t, "user", ownerType) + }) + + t.Run("falls back to project probes", func(t *testing.T) { + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetUsersProjectsV2ByUsernameByProject: mockResponse(t, http.StatusNotFound, nil), + GetOrgsProjectsV2ByProject: mockResponse(t, http.StatusOK, map[string]any{"id": 1}), + }) + client := mustNewGHClient(t, mockedClient) + + ownerType, err := detectOwnerType(context.Background(), client, "octo-org", 1) + + require.NoError(t, err) + assert.Equal(t, "org", ownerType) + }) +} + +func Test_ProjectsList_IFC_InsidersMode(t *testing.T) { + toolDef := ProjectsList(translations.NullTranslationHelper) + + t.Run("list_projects joins returned project visibilities", func(t *testing.T) { + projects := []map[string]any{ + {"id": 1, "node_id": "NODE1", "title": "Public Project", "public": true}, + {"id": 2, "node_id": "NODE2", "title": "Private Project", "public": false}, + } + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetOrgsProjectsV2: mockResponse(t, http.StatusOK, projects), + }) + client := mustNewGHClient(t, mockedClient) + deps := BaseDeps{ + Client: client, + featureChecker: featureCheckerFor(FeatureFlagIFCLabels), + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "list_projects", + "owner": "octo-org", + "owner_type": "org", + }) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + require.NotNil(t, result.Meta) + ifcMap := unmarshalIFC(t, result.Meta["ifc"]) + assert.Equal(t, "untrusted", ifcMap["integrity"]) + assert.Equal(t, "private", ifcMap["confidentiality"]) + }) + + t.Run("list_project_fields uses project metadata label", func(t *testing.T) { + fields := []map[string]any{{"id": 101, "name": "Status", "data_type": "single_select"}} + project := map[string]any{"id": 1, "node_id": "NODE1", "title": "Private Project", "public": false} + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetOrgsProjectsV2FieldsByProject: mockResponse(t, http.StatusOK, fields), + GetOrgsProjectsV2ByProject: mockResponse(t, http.StatusOK, project), + }) + client := mustNewGHClient(t, mockedClient) + deps := BaseDeps{ + Client: client, + featureChecker: featureCheckerFor(FeatureFlagIFCLabels), + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "list_project_fields", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + }) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + require.NotNil(t, result.Meta) + ifcMap := unmarshalIFC(t, result.Meta["ifc"]) + assert.Equal(t, "trusted", ifcMap["integrity"]) + assert.Equal(t, "private", ifcMap["confidentiality"]) + }) + + t.Run("list_project_items uses project content label", func(t *testing.T) { + items := []map[string]any{verbosePullRequestProjectItemFixture()} + project := map[string]any{"id": 1, "node_id": "NODE1", "title": "Private Project", "public": false} + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetOrgsProjectsV2ItemsByProject: mockResponse(t, http.StatusOK, items), + GetOrgsProjectsV2ByProject: mockResponse(t, http.StatusOK, project), + }) + client := mustNewGHClient(t, mockedClient) + deps := BaseDeps{ + Client: client, + featureChecker: featureCheckerFor(FeatureFlagIFCLabels), + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "list_project_items", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + }) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + require.NotNil(t, result.Meta) + ifcMap := unmarshalIFC(t, result.Meta["ifc"]) + assert.Equal(t, "untrusted", ifcMap["integrity"]) + assert.Equal(t, "private", ifcMap["confidentiality"]) + }) + + t.Run("list_project_status_updates uses GraphQL project visibility", func(t *testing.T) { + gqlMockedClient := githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + statusUpdatesOrgQuery{}, + map[string]any{ + "owner": githubv4.String("octo-org"), + "projectNumber": githubv4.Int(1), + "first": githubv4.Int(50), + "after": (*githubv4.String)(nil), + }, + githubv4mock.DataResponse(map[string]any{ + "organization": map[string]any{ + "projectV2": map[string]any{ + "public": true, + "statusUpdates": map[string]any{ + "nodes": []map[string]any{}, + "pageInfo": map[string]any{ + "hasNextPage": false, + "hasPreviousPage": false, + "startCursor": "", + "endCursor": "", + }, + }, + }, + }, + }), + ), + ) + deps := BaseDeps{ + Client: mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{})), + GQLClient: githubv4.NewClient(gqlMockedClient), + featureChecker: featureCheckerFor(FeatureFlagIFCLabels), + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "list_project_status_updates", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + }) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + require.NotNil(t, result.Meta) + ifcMap := unmarshalIFC(t, result.Meta["ifc"]) + assert.Equal(t, "untrusted", ifcMap["integrity"]) + assert.Equal(t, "public", ifcMap["confidentiality"]) + }) +} + func Test_ProjectsGet(t *testing.T) { // Verify tool definition once toolDef := ProjectsGet(translations.NullTranslationHelper) @@ -438,6 +628,79 @@ func Test_ProjectsGet_GetProject(t *testing.T) { }) } +func Test_ProjectsGet_IFC_InsidersMode(t *testing.T) { + toolDef := ProjectsGet(translations.NullTranslationHelper) + + t.Run("get_project uses project metadata label", func(t *testing.T) { + project := map[string]any{"id": 123, "node_id": "NODE1", "title": "Private Project", "public": false} + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetOrgsProjectsV2ByProject: mockResponse(t, http.StatusOK, project), + }) + client := mustNewGHClient(t, mockedClient) + deps := BaseDeps{ + Client: client, + featureChecker: featureCheckerFor(FeatureFlagIFCLabels), + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "get_project", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + }) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + require.NotNil(t, result.Meta) + ifcMap := unmarshalIFC(t, result.Meta["ifc"]) + assert.Equal(t, "trusted", ifcMap["integrity"]) + assert.Equal(t, "private", ifcMap["confidentiality"]) + }) + + t.Run("get_project_status_update uses GraphQL project visibility", func(t *testing.T) { + gqlMockedClient := githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + statusUpdateNodeQuery{}, + map[string]any{ + "id": githubv4.ID("SU_abc123"), + }, + githubv4mock.DataResponse(map[string]any{ + "node": map[string]any{ + "id": "SU_abc123", + "body": "On track", + "status": "ON_TRACK", + "createdAt": "2026-01-15T10:00:00Z", + "startDate": "2026-01-01", + "targetDate": "2026-03-01", + "creator": map[string]any{"login": "octocat"}, + "project": map[string]any{"public": true}, + }, + }), + ), + ) + deps := BaseDeps{ + GQLClient: githubv4.NewClient(gqlMockedClient), + featureChecker: featureCheckerFor(FeatureFlagIFCLabels), + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "get_project_status_update", + "status_update_id": "SU_abc123", + }) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + require.NotNil(t, result.Meta) + ifcMap := unmarshalIFC(t, result.Meta["ifc"]) + assert.Equal(t, "untrusted", ifcMap["integrity"]) + assert.Equal(t, "public", ifcMap["confidentiality"]) + }) +} + func Test_ProjectsGet_GetProjectField(t *testing.T) { toolDef := ProjectsGet(translations.NullTranslationHelper) @@ -1091,6 +1354,7 @@ func Test_ProjectsList_ListProjectStatusUpdates(t *testing.T) { githubv4mock.DataResponse(map[string]any{ "user": map[string]any{ "projectV2": map[string]any{ + "public": false, "statusUpdates": map[string]any{ "nodes": []map[string]any{ { @@ -1161,6 +1425,7 @@ func Test_ProjectsGet_GetProjectStatusUpdate(t *testing.T) { "startDate": "2026-01-01", "targetDate": "2026-03-01", "creator": map[string]any{"login": "octocat"}, + "project": map[string]any{"public": false}, }, }), ), diff --git a/pkg/github/pullrequests.go b/pkg/github/pullrequests.go index ae7d04331d..ef3e9c0839 100644 --- a/pkg/github/pullrequests.go +++ b/pkg/github/pullrequests.go @@ -115,7 +115,7 @@ Possible options: // visibility lookup fails the label is omitted rather than // misclassifying the result. attachIFC := func(r *mcp.CallToolResult) *mcp.CallToolResult { - return attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, r, ifc.LabelListIssues) + return attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, r, ifc.LabelRepoUserContent) } switch method { @@ -587,6 +587,9 @@ func GetPullRequestReviews(ctx context.Context, client *github.Client, deps Tool // PullRequestWriteUIResourceURI is the URI for the create_pull_request tool's MCP App UI resource. const PullRequestWriteUIResourceURI = "ui://github-mcp-server/pr-write" +// PullRequestEditUIResourceURI is the URI for the update_pull_request tool's MCP App UI resource. +const PullRequestEditUIResourceURI = "ui://github-mcp-server/pr-edit" + // pullRequestWriteFormParams are the parameters the create_pull_request MCP App // form collects and re-sends on submit. Any other parameter present on a call // cannot be represented by the form. @@ -599,6 +602,22 @@ var pullRequestWriteFormParams = map[string]struct{}{ "base": {}, "draft": {}, "maintainer_can_modify": {}, + "reviewers": {}, + "show_ui": {}, + "_ui_submitted": {}, +} + +var pullRequestUpdateFormParams = map[string]struct{}{ + "owner": {}, + "repo": {}, + "pullNumber": {}, + "title": {}, + "body": {}, + "state": {}, + "draft": {}, + "base": {}, + "maintainer_can_modify": {}, + "reviewers": {}, "_ui_submitted": {}, } @@ -618,6 +637,18 @@ func pullRequestWriteHasNonFormParams(args map[string]any) bool { return false } +func pullRequestUpdateHasNonFormParams(args map[string]any) bool { + for key, value := range args { + if value == nil { + continue + } + if _, ok := pullRequestUpdateFormParams[key]; !ok { + return true + } + } + return false +} + // CreatePullRequest creates a tool to create a new pull request. func CreatePullRequest(t translations.TranslationHelperFunc) inventory.ServerTool { return NewTool( @@ -670,6 +701,24 @@ func CreatePullRequest(t translations.TranslationHelperFunc) inventory.ServerToo Type: "boolean", Description: "Allow maintainer edits", }, + "reviewers": { + Type: "array", + Description: "GitHub usernames or ORG/team-slug team reviewers to request reviews from", + Items: &jsonschema.Schema{ + Type: "string", + }, + }, + // show_ui is hidden from clients that do not advertise MCP App + // UI support. The strip happens per-request in + // inventory.ToolsForRegistration; it is present in the static + // schema (and therefore in toolsnaps and the feature-flag / + // insiders docs) so the UI-capable surface is fully + // documented. It is intentionally not in the main README, + // which renders the stripped (non-UI) schema. + "show_ui": { + Type: "boolean", + Description: "Whether to render the MCP App form instead of executing the request immediately. Defaults to true. Set to false to skip the form and execute directly — useful when you have all required values (especially ones the form does not collect, like reviewers) and the user has already confirmed the action.", + }, }, Required: []string{"owner", "repo", "title", "head", "base"}, }, @@ -686,14 +735,26 @@ func CreatePullRequest(t translations.TranslationHelperFunc) inventory.ServerToo } // When MCP Apps are enabled and the client supports UI, route the - // call to the interactive form unless it is itself a form submission - // (the UI sends _ui_submitted=true) or it carries parameters the form - // cannot represent. Those must be applied directly so their values - // aren't silently dropped. + // call to the interactive form unless: + // - it is itself a form submission (the UI sends _ui_submitted=true), + // - the caller explicitly asked to skip the UI (show_ui=false), or + // - it carries parameters the form cannot represent. Those must be + // applied directly so their values aren't silently dropped. uiSubmitted, _ := OptionalParam[bool](args, "_ui_submitted") + showUI, err := OptionalBoolParamWithDefault(args, "show_ui", true) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } - if deps.IsFeatureEnabled(ctx, MCPAppsFeatureFlag) && clientSupportsUI(ctx, req) && !uiSubmitted && !pullRequestWriteHasNonFormParams(args) { - return utils.NewToolResultText(fmt.Sprintf("Ready to create a pull request in %s/%s. IMPORTANT: The PR has NOT been created yet. Do NOT tell the user the PR was created. The user MUST click Submit in the form to create it.", owner, repo)), nil, nil + if deps.IsFeatureEnabled(ctx, MCPAppsFeatureFlag) && clientSupportsUI(ctx, req) && !uiSubmitted && showUI && !pullRequestWriteHasNonFormParams(args) { + return utils.NewToolResultAwaitingFormSubmission(fmt.Sprintf( + "An interactive form has been shown to the user for creating a new pull request in %s/%s. "+ + "STOP — do not call any other tools, do not respond as if the pull request was created, "+ + "and do not claim the operation succeeded. The pull request has NOT been created yet; "+ + "only the form was rendered. Wait silently for the user to review and click Submit. "+ + "When they do, the real result will be delivered to your context automatically.", + owner, repo, + )), nil, nil } // When creating PR, title/head/base are required @@ -734,6 +795,11 @@ func CreatePullRequest(t translations.TranslationHelperFunc) inventory.ServerToo return utils.NewToolResultError(err.Error()), nil, nil } + reviewers, err := OptionalStringArrayParam(args, "reviewers") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + newPR := &github.NewPullRequest{ Title: github.Ptr(title), Head: github.Ptr(head), @@ -769,6 +835,36 @@ func CreatePullRequest(t translations.TranslationHelperFunc) inventory.ServerToo return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to create pull request", resp, bodyBytes), nil, nil } + if len(reviewers) > 0 { + userReviewers, teamReviewers := splitPullRequestReviewers(reviewers) + reviewersRequest := github.ReviewersRequest{ + Reviewers: userReviewers, + TeamReviewers: teamReviewers, + } + + _, reviewerResp, err := client.PullRequests.RequestReviewers(ctx, owner, repo, pr.GetNumber(), reviewersRequest) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to request reviewers", + reviewerResp, + err, + ), nil, nil + } + defer func() { + if reviewerResp != nil && reviewerResp.Body != nil { + _ = reviewerResp.Body.Close() + } + }() + + if reviewerResp.StatusCode != http.StatusCreated && reviewerResp.StatusCode != http.StatusOK { + bodyBytes, err := io.ReadAll(reviewerResp.Body) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to read response body", err), nil, nil + } + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to request reviewers", reviewerResp, bodyBytes), nil, nil + } + } + // Return minimal response with just essential information minimalResponse := MinimalResponse{ ID: fmt.Sprintf("%d", pr.GetID()), @@ -846,10 +942,16 @@ func UpdatePullRequest(t translations.TranslationHelperFunc) inventory.ServerToo Title: t("TOOL_UPDATE_PULL_REQUEST_USER_TITLE", "Edit pull request"), ReadOnlyHint: false, }, + Meta: mcp.Meta{ + "ui": map[string]any{ + "resourceUri": PullRequestEditUIResourceURI, + "visibility": []string{"model", "app"}, + }, + }, InputSchema: schema, }, []scopes.Scope{scopes.Repo}, - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + func(ctx context.Context, deps ToolDependencies, req *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil @@ -863,6 +965,18 @@ func UpdatePullRequest(t translations.TranslationHelperFunc) inventory.ServerToo return utils.NewToolResultError(err.Error()), nil, nil } + uiSubmitted, _ := OptionalParam[bool](args, "_ui_submitted") + if deps.IsFeatureEnabled(ctx, MCPAppsFeatureFlag) && clientSupportsUI(ctx, req) && !uiSubmitted && !pullRequestUpdateHasNonFormParams(args) { + return utils.NewToolResultAwaitingFormSubmission(fmt.Sprintf( + "An interactive form has been shown to the user for editing pull request #%d in %s/%s. "+ + "STOP — do not call any other tools, do not respond as if the pull request was updated, "+ + "and do not claim the operation succeeded. The pull request has NOT been updated yet; "+ + "only the form was rendered. Wait silently for the user to review and click Submit. "+ + "When they do, the real result will be delivered to your context automatically.", + pullNumber, owner, repo, + )), nil, nil + } + _, draftProvided := args["draft"] var draftValue bool if draftProvided { @@ -1322,7 +1436,7 @@ func ListPullRequests(t translations.TranslationHelperFunc) inventory.ServerTool result := utils.NewToolResultText(string(r)) // Pull request titles/bodies are user-authored (untrusted); // confidentiality follows repo visibility. - result = attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, result, ifc.LabelListIssues) + result = attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, result, ifc.LabelRepoUserContent) return result, nil, nil }) } diff --git a/pkg/github/pullrequests_test.go b/pkg/github/pullrequests_test.go index 2b911636a9..0f372519e5 100644 --- a/pkg/github/pullrequests_test.go +++ b/pkg/github/pullrequests_test.go @@ -2629,7 +2629,8 @@ func Test_CreatePullRequest_MCPAppsFeature_UIGate(t *testing.T) { require.NoError(t, err) textContent := getTextResult(t, result) - assert.Contains(t, textContent.Text, "Ready to create a pull request") + assert.Contains(t, textContent.Text, "interactive form has been shown to the user for creating a new pull request") + assert.True(t, result.IsError, "form-routing stub should be marked IsError so agents don't claim success") }) t.Run("UI client with _ui_submitted executes directly", func(t *testing.T) { @@ -2669,22 +2670,201 @@ func Test_CreatePullRequest_MCPAppsFeature_UIGate(t *testing.T) { // A parameter the form does not collect must bypass the form rather than // be silently dropped. request := createMCPRequestWithSession(t, ClientNameVSCodeInsiders, true, map[string]any{ - "owner": "owner", - "repo": "repo", - "title": "Test PR", - "head": "feature", - "base": "main", - "reviewers": []any{"octocat"}, + "owner": "owner", + "repo": "repo", + "title": "Test PR", + "head": "feature", + "base": "main", + "unknown_param": "value", }) result, err := handler(ContextWithDeps(context.Background(), deps), &request) require.NoError(t, err) textContent := getTextResult(t, result) - assert.NotContains(t, textContent.Text, "Ready to create a pull request", + assert.NotContains(t, textContent.Text, "interactive form has been shown", "non-form param should skip UI form") assert.Contains(t, textContent.Text, "https://github.com/owner/repo/pull/42", "non-form param call should execute directly and return PR URL") }) + + t.Run("UI client with show_ui=false skips form and executes directly", func(t *testing.T) { + // show_ui=false is the explicit, model-facing way to opt out of the + // form. It must bypass the form even when every other condition would + // route the call there (UI capability, MCP Apps flag on, no + // _ui_submitted, only form params present). + request := createMCPRequestWithSession(t, ClientNameVSCodeInsiders, true, map[string]any{ + "owner": "owner", + "repo": "repo", + "title": "Test PR", + "head": "feature", + "base": "main", + "show_ui": false, + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + + textContent := getTextResult(t, result) + assert.NotContains(t, textContent.Text, "interactive form has been shown", + "show_ui=false should skip UI form") + assert.Contains(t, textContent.Text, "https://github.com/owner/repo/pull/42", + "show_ui=false call should execute directly and return PR URL") + }) + + t.Run("UI client with show_ui=true returns form message", func(t *testing.T) { + // show_ui=true must still route through the form and must not be + // treated as a non-form parameter that would trigger the safety-net + // bypass. + request := createMCPRequestWithSession(t, ClientNameVSCodeInsiders, true, map[string]any{ + "owner": "owner", + "repo": "repo", + "title": "Test PR", + "head": "feature", + "base": "main", + "show_ui": true, + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, "interactive form has been shown", + "show_ui=true should still route through the form") + }) + + t.Run("UI client with show_ui=false and _ui_submitted=true executes directly", func(t *testing.T) { + // _ui_submitted and show_ui=false are two ways to say "execute + // directly". When both are set there must be no conflict — the call + // still executes directly. + request := createMCPRequestWithSession(t, ClientNameVSCodeInsiders, true, map[string]any{ + "owner": "owner", + "repo": "repo", + "title": "Test PR", + "head": "feature", + "base": "main", + "show_ui": false, + "_ui_submitted": true, + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, "https://github.com/owner/repo/pull/42", + "show_ui=false + _ui_submitted should execute directly") + }) + + t.Run("non-UI client with show_ui=false executes directly (no regression)", func(t *testing.T) { + // show_ui is irrelevant when the client does not support UI; the call + // must execute directly exactly as it does today. + request := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "title": "Test PR", + "head": "feature", + "base": "main", + "show_ui": false, + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, "https://github.com/owner/repo/pull/42", + "non-UI client should execute directly regardless of show_ui") + }) +} + +// Test_UpdatePullRequest_MCPAppsFeature_UIGate verifies the form-routing +// behavior for update_pull_request: UI clients without _ui_submitted get a +// pending-form stub (marked IsError so agents don't claim success), UI clients +// with _ui_submitted execute directly, non-UI clients execute directly, and +// UI clients carrying non-form params bypass the form. +func Test_UpdatePullRequest_MCPAppsFeature_UIGate(t *testing.T) { + t.Parallel() + + mockPR := &github.PullRequest{ + Number: github.Ptr(42), + Title: github.Ptr("Updated"), + HTMLURL: github.Ptr("https://github.com/owner/repo/pull/42"), + Head: &github.PullRequestBranch{SHA: github.Ptr("abc"), Ref: github.Ptr("feature")}, + Base: &github.PullRequestBranch{SHA: github.Ptr("def"), Ref: github.Ptr("main")}, + User: &github.User{Login: github.Ptr("testuser")}, + } + + serverTool := UpdatePullRequest(translations.NullTranslationHelper) + + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposPullsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, mockPR), + GetReposPullsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, mockPR), + })) + + deps := BaseDeps{ + Client: client, + GQLClient: githubv4.NewClient(nil), + featureChecker: featureCheckerFor(MCPAppsFeatureFlag), + } + handler := serverTool.Handler(deps) + + t.Run("UI client without _ui_submitted returns form message", func(t *testing.T) { + request := createMCPRequestWithSession(t, ClientNameVSCodeInsiders, true, map[string]any{ + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + "title": "Updated", + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, "interactive form has been shown to the user for editing pull request #42") + assert.True(t, result.IsError, "form-routing stub should be marked IsError so agents don't claim success") + }) + + t.Run("UI client with _ui_submitted executes directly", func(t *testing.T) { + request := createMCPRequestWithSession(t, ClientNameVSCodeInsiders, true, map[string]any{ + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + "title": "Updated", + "_ui_submitted": true, + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + + textContent := getTextResult(t, result) + assert.False(t, result.IsError, "submitted form should execute successfully: %s", textContent.Text) + assert.Contains(t, textContent.Text, "https://github.com/owner/repo/pull/42", + "submitted form should return the updated PR URL") + }) + + t.Run("non-UI client executes directly without _ui_submitted", func(t *testing.T) { + request := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + "title": "Updated", + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + + textContent := getTextResult(t, result) + assert.False(t, result.IsError, "non-UI client should execute directly: %s", textContent.Text) + assert.Contains(t, textContent.Text, "https://github.com/owner/repo/pull/42", + "non-UI client should return the updated PR URL") + }) + + t.Run("UI client with non-form param skips form and executes directly", func(t *testing.T) { + request := createMCPRequestWithSession(t, ClientNameVSCodeInsiders, true, map[string]any{ + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + "title": "Updated", + "unknown_param": "value", + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + + textContent := getTextResult(t, result) + assert.NotContains(t, textContent.Text, "interactive form has been shown", + "non-form param should skip UI form") + }) } func Test_pullRequestWriteHasNonFormParams(t *testing.T) { @@ -2696,8 +2876,10 @@ func Test_pullRequestWriteHasNonFormParams(t *testing.T) { want bool }{ {name: "no params", args: map[string]any{}, want: false}, - {name: "only form params", args: map[string]any{"owner": "o", "repo": "r", "title": "t", "body": "b", "head": "h", "base": "b", "draft": true, "maintainer_can_modify": false, "_ui_submitted": true}, want: false}, - {name: "unknown param present", args: map[string]any{"title": "t", "reviewers": []any{"octocat"}}, want: true}, + {name: "only form params", args: map[string]any{"owner": "o", "repo": "r", "title": "t", "body": "b", "head": "h", "base": "b", "draft": true, "maintainer_can_modify": false, "reviewers": []any{"octocat"}, "show_ui": true, "_ui_submitted": true}, want: false}, + {name: "show_ui true is a form param", args: map[string]any{"title": "t", "show_ui": true}, want: false}, + {name: "show_ui false is a form param", args: map[string]any{"title": "t", "show_ui": false}, want: false}, + {name: "unknown param present", args: map[string]any{"title": "t", "unknown_param": "value"}, want: true}, {name: "nil value is ignored", args: map[string]any{"reviewers": nil}, want: false}, } @@ -2709,6 +2891,32 @@ func Test_pullRequestWriteHasNonFormParams(t *testing.T) { } } +// Test_createPullRequestSchemaClassification fails when a schema property is +// added without classifying it as either form-resendable +// (pullRequestWriteFormParams) or known-non-form (knownNonForm below). +// Today every property is form-resendable, so knownNonForm is empty. +func Test_createPullRequestSchemaClassification(t *testing.T) { + t.Parallel() + + knownNonForm := map[string]struct{}{} + + tool := CreatePullRequest(translations.NullTranslationHelper) + schema, ok := tool.Tool.InputSchema.(*jsonschema.Schema) + require.True(t, ok, "InputSchema should be *jsonschema.Schema") + + for prop := range schema.Properties { + _, isForm := pullRequestWriteFormParams[prop] + _, isNonForm := knownNonForm[prop] + + assert.Falsef(t, isForm && isNonForm, + "property %q is classified as both form-resendable and non-form — pick one", prop) + assert.Truef(t, isForm || isNonForm, + "property %q in create_pull_request schema is unclassified — add it to pullRequestWriteFormParams "+ + "(pkg/github/pullrequests.go) if the MCP App form can carry it on submit, otherwise add it to "+ + "the knownNonForm allowlist in this test", prop) + } +} + func TestCreateAndSubmitPullRequestReview(t *testing.T) { t.Parallel() diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go index 60bb45c44f..949a180081 100644 --- a/pkg/github/repositories.go +++ b/pkg/github/repositories.go @@ -600,7 +600,8 @@ func CreateRepository(t translations.TranslationHelperFunc) inventory.ServerTool }, "private": { Type: "boolean", - Description: "Whether repo should be private", + Description: "Whether the repository should be private. Defaults to true (private) when omitted.", + Default: json.RawMessage("true"), }, "autoInit": { Type: "boolean", @@ -624,7 +625,7 @@ func CreateRepository(t translations.TranslationHelperFunc) inventory.ServerTool if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } - private, err := OptionalParam[bool](args, "private") + private, err := OptionalBoolParamWithDefault(args, "private", true) if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } @@ -2120,9 +2121,10 @@ func ListStarredRepositories(t translations.TranslationHelperFunc) inventory.Ser result := utils.NewToolResultText(string(r)) // A starred-repository listing exposes repository data across many // repos; reuse the multi-repo join shared with search_repositories - // (untrusted integrity; confidentiality private if any matched repo - // is private). Visibility is read directly from the response, so no - // extra API call is needed. + // (public-only results stay public-untrusted, mixed-visibility + // results become private-untrusted, all-private results become + // private-trusted). Visibility is read directly from the response, + // so no extra API call is needed. visibilities := make([]bool, 0, len(minimalRepos)) for _, mr := range minimalRepos { visibilities = append(visibilities, mr.Private) diff --git a/pkg/github/repositories_test.go b/pkg/github/repositories_test.go index 8b0b196a63..e5531cc55b 100644 --- a/pkg/github/repositories_test.go +++ b/pkg/github/repositories_test.go @@ -2020,7 +2020,7 @@ func Test_CreateRepository(t *testing.T) { expectedRepo: mockRepo, }, { - name: "successful repository creation with minimal parameters", + name: "successful repository creation with minimal parameters defaults to private", mockedClient: NewMockedHTTPClient( WithRequestMatchHandler( EndpointPattern("POST /user/repos"), @@ -2028,7 +2028,7 @@ func Test_CreateRepository(t *testing.T) { "name": "test-repo", "auto_init": false, "description": "", - "private": false, + "private": true, }).andThen( mockResponse(t, http.StatusCreated, mockRepo), ), @@ -2040,6 +2040,28 @@ func Test_CreateRepository(t *testing.T) { expectError: false, expectedRepo: mockRepo, }, + { + name: "successful public repository creation when private is explicitly false", + mockedClient: NewMockedHTTPClient( + WithRequestMatchHandler( + EndpointPattern("POST /user/repos"), + expectRequestBody(t, map[string]any{ + "name": "test-repo", + "auto_init": false, + "description": "", + "private": false, + }).andThen( + mockResponse(t, http.StatusCreated, mockRepo), + ), + ), + ), + requestArgs: map[string]any{ + "name": "test-repo", + "private": false, + }, + expectError: false, + expectedRepo: mockRepo, + }, { name: "repository creation fails", mockedClient: NewMockedHTTPClient( diff --git a/pkg/github/search.go b/pkg/github/search.go index 42ba2896f3..23ccbd8387 100644 --- a/pkg/github/search.go +++ b/pkg/github/search.go @@ -173,8 +173,9 @@ func SearchRepositories(t translations.TranslationHelperFunc) inventory.ServerTo // every matched repository and attaches the result to callResult when IFC // labels are enabled. Visibility is read directly from the search response — // no extra API call. The join math is shared with search_issues via -// ifc.LabelSearchIssues: integrity is always untrusted; confidentiality is -// private if any matched repository is private, otherwise public. The +// ifc.LabelSearchIssues: public-only results stay public-untrusted, +// mixed-visibility results become private-untrusted, and all-private results +// become private-trusted. The // feature-flag check is centralized here (mirroring the attach* helpers in // ifc_labels.go) so the handler can call this unconditionally. func attachSearchRepositoriesIFCLabel(ctx context.Context, deps ToolDependencies, repos []*github.Repository, callResult *mcp.CallToolResult) { @@ -302,9 +303,9 @@ func SearchCode(t translations.TranslationHelperFunc) inventory.ServerTool { } callResult := utils.NewToolResultText(string(r)) - // Code search spans repositories and exposes file contents - // (untrusted). Confidentiality is the IFC join across every matched - // repository's visibility, read directly from the search response. + // Code search spans repositories; the IFC label is the conservative + // join across every matched repository's visibility, read directly + // from the search response. visibilities := make([]bool, 0, len(result.CodeResults)) for _, code := range result.CodeResults { if code.Repository != nil { @@ -593,9 +594,9 @@ func SearchCommits(t translations.TranslationHelperFunc) inventory.ServerTool { } callResult := utils.NewToolResultText(string(r)) - // Commit search spans repositories and exposes commit content - // (untrusted). Confidentiality is the IFC join across every matched - // repository's visibility, read directly from the search response. + // Commit search spans repositories; the IFC label is the conservative + // join across every matched repository's visibility, read directly + // from the search response. visibilities := make([]bool, 0, len(result.Commits)) for _, commit := range result.Commits { if commit.Repository != nil { diff --git a/pkg/github/search_test.go b/pkg/github/search_test.go index fa48bf19a1..5ebf60842a 100644 --- a/pkg/github/search_test.go +++ b/pkg/github/search_test.go @@ -238,7 +238,7 @@ func Test_SearchRepositories_IFC_InsidersMode(t *testing.T) { assert.Equal(t, "public", ifcMap["confidentiality"]) }) - t.Run("insiders mode any private match emits private untrusted", func(t *testing.T) { + t.Run("insiders mode mixed public and private emits private untrusted", func(t *testing.T) { deps := BaseDeps{ Client: mustNewGHClient(t, makeMockClient([]repoFixture{ {owner: "octocat", name: "private-repo", isPrivate: true}, diff --git a/pkg/github/tools.go b/pkg/github/tools.go index 906fa777d7..2c894e5738 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -5,10 +5,11 @@ import ( "slices" "strings" - "github.com/github/github-mcp-server/pkg/inventory" - "github.com/github/github-mcp-server/pkg/translations" "github.com/google/go-github/v87/github" "github.com/shurcooL/githubv4" + + "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/translations" ) type GetClientFn func(context.Context) (*github.Client, error) @@ -76,6 +77,11 @@ var ( Description: "GitHub Actions workflows and CI/CD operations", Icon: "workflow", } + ToolsetMetadataCodeQuality = inventory.ToolsetMetadata{ + ID: "code_quality", + Description: "GitHub Code Quality related tools", + Icon: "code-square", + } ToolsetMetadataCodeSecurity = inventory.ToolsetMetadata{ ID: "code_security", Description: "Code security related tools, such as GitHub Code Scanning", @@ -235,6 +241,9 @@ func AllTools(t translations.TranslationHelperFunc) []inventory.ServerTool { AssignCopilotToIssue(t), RequestCopilotReview(t), + // Code quality tools + GetCodeQualityFinding(t), + // Code security tools GetCodeScanningAlert(t), ListCodeScanningAlerts(t), @@ -291,6 +300,9 @@ func AllTools(t translations.TranslationHelperFunc) []inventory.ServerTool { ListLabels(t), LabelWrite(t), + // UI tools (insiders only) + UIGet(t), + // Granular issue tools (feature-flagged, replace consolidated issue_write/sub_issue_write) GranularCreateIssue(t), GranularUpdateIssueTitle(t), diff --git a/pkg/github/ui_resources.go b/pkg/github/ui_resources.go index 28051c0c4a..045e129360 100644 --- a/pkg/github/ui_resources.go +++ b/pkg/github/ui_resources.go @@ -107,4 +107,31 @@ func RegisterUIResources(s *mcp.Server, readOnly bool) { }, nil }, ) + + s.AddResource( + &mcp.Resource{ + URI: PullRequestEditUIResourceURI, + Name: "pr_edit_ui", + Description: "MCP App UI for editing GitHub pull requests", + MIMEType: MCPAppMIMEType, + }, + func(_ context.Context, _ *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { + html := MustGetUIAsset("pr-edit.html") + return &mcp.ReadResourceResult{ + Contents: []*mcp.ResourceContents{ + { + URI: PullRequestEditUIResourceURI, + MIMEType: MCPAppMIMEType, + Text: html, + Meta: mcp.Meta{ + "ui": map[string]any{ + "csp": map[string]any{}, + "prefersBorder": true, + }, + }, + }, + }, + }, nil + }, + ) } diff --git a/pkg/github/ui_resources_test.go b/pkg/github/ui_resources_test.go index 7e67d5faed..49cce09bbd 100644 --- a/pkg/github/ui_resources_test.go +++ b/pkg/github/ui_resources_test.go @@ -55,6 +55,7 @@ func TestRegisterUIResources_ReadableViaClient(t *testing.T) { GetMeUIResourceURI, IssueWriteUIResourceURI, PullRequestWriteUIResourceURI, + PullRequestEditUIResourceURI, } for _, uri := range uris { t.Run(uri, func(t *testing.T) { diff --git a/pkg/github/ui_tools.go b/pkg/github/ui_tools.go new file mode 100644 index 0000000000..640250dea3 --- /dev/null +++ b/pkg/github/ui_tools.go @@ -0,0 +1,516 @@ +package github + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "sort" + "strings" + + ghErrors "github.com/github/github-mcp-server/pkg/errors" + "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/scopes" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/github/github-mcp-server/pkg/utils" + "github.com/google/go-github/v87/github" + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/shurcooL/githubv4" +) + +// UIGet creates a tool to fetch UI data for MCP Apps. +func UIGet(t translations.TranslationHelperFunc) inventory.ServerTool { + st := NewTool( + ToolsetMetadataContext, // Use context toolset so it's always available + mcp.Tool{ + Name: "ui_get", + Description: t("TOOL_UI_GET_DESCRIPTION", "Fetch UI data for MCP Apps (labels, assignees, milestones, issue types, branches, issue fields, reviewers)."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_UI_GET_USER_TITLE", "Get UI data"), + ReadOnlyHint: true, + }, + // ui_get only backs MCP App views; declaring app-only visibility keeps + // it out of the agent's tool list while remaining callable by the views + // via tools/call (per the MCP Apps 2026-01-26 spec). + Meta: mcp.Meta{ + "ui": map[string]any{ + "visibility": []string{"app"}, + }, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "method": { + Type: "string", + Enum: []any{"labels", "assignees", "milestones", "issue_types", "branches", "issue_fields", "reviewers"}, + Description: "The type of data to fetch", + }, + "owner": { + Type: "string", + Description: "Repository owner (required for all methods)", + }, + "repo": { + Type: "string", + Description: "Repository name (required for labels, assignees, milestones, branches, issue fields, reviewers)", + }, + }, + Required: []string{"method", "owner"}, + }, + }, + []scopes.Scope{scopes.Repo, scopes.ReadOrg}, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + method, err := RequiredParam[string](args, "method") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + switch method { + case "labels": + return uiGetLabels(ctx, deps, args, owner) + case "assignees": + return uiGetAssignees(ctx, deps, args, owner) + case "milestones": + return uiGetMilestones(ctx, deps, args, owner) + case "issue_types": + return uiGetIssueTypes(ctx, deps, owner) + case "branches": + return uiGetBranches(ctx, deps, args, owner) + case "issue_fields": + return uiGetIssueFields(ctx, deps, args, owner) + case "reviewers": + return uiGetReviewers(ctx, deps, args, owner) + default: + return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil + } + }) + st.FeatureFlagEnable = MCPAppsFeatureFlag + return st +} + +func uiGetLabels(ctx context.Context, deps ToolDependencies, args map[string]any, owner string) (*mcp.CallToolResult, any, error) { + repo, err := RequiredParam[string](args, "repo") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + client, err := deps.GetGQLClient(ctx) + if err != nil { + return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + var query struct { + Repository struct { + Labels struct { + Nodes []struct { + ID githubv4.ID + Name githubv4.String + Color githubv4.String + Description githubv4.String + } + TotalCount githubv4.Int + PageInfo struct { + HasNextPage githubv4.Boolean + EndCursor githubv4.String + } + } `graphql:"labels(first: 100, after: $cursor)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + } + + vars := map[string]any{ + "owner": githubv4.String(owner), + "repo": githubv4.String(repo), + "cursor": (*githubv4.String)(nil), + } + + labels := make([]map[string]any, 0) + var totalCount int + for { + if err := client.Query(ctx, &query, vars); err != nil { + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to list labels", err), nil, nil + } + for _, labelNode := range query.Repository.Labels.Nodes { + labels = append(labels, map[string]any{ + "id": fmt.Sprintf("%v", labelNode.ID), + "name": string(labelNode.Name), + "color": string(labelNode.Color), + "description": string(labelNode.Description), + }) + } + totalCount = int(query.Repository.Labels.TotalCount) + if !query.Repository.Labels.PageInfo.HasNextPage { + break + } + vars["cursor"] = githubv4.NewString(query.Repository.Labels.PageInfo.EndCursor) + } + + response := map[string]any{ + "labels": labels, + "totalCount": totalCount, + } + + out, err := json.Marshal(response) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal labels: %w", err) + } + + return utils.NewToolResultText(string(out)), nil, nil +} + +func uiGetAssignees(ctx context.Context, deps ToolDependencies, args map[string]any, owner string) (*mcp.CallToolResult, any, error) { + repo, err := RequiredParam[string](args, "repo") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + client, err := deps.GetClient(ctx) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil + } + + opts := &github.ListOptions{PerPage: 100} + var allAssignees []*github.User + + for { + assignees, resp, err := client.Issues.ListAssignees(ctx, owner, repo, opts) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list assignees", resp, err), nil, nil + } + allAssignees = append(allAssignees, assignees...) + if resp != nil && resp.Body != nil { + _ = resp.Body.Close() + } + if resp.NextPage == 0 { + break + } + opts.Page = resp.NextPage + } + + result := make([]map[string]string, len(allAssignees)) + for i, u := range allAssignees { + result[i] = map[string]string{ + "login": u.GetLogin(), + "avatar_url": u.GetAvatarURL(), + } + } + + out, err := json.Marshal(map[string]any{ + "assignees": result, + "totalCount": len(result), + }) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to marshal assignees", err), nil, nil + } + + return utils.NewToolResultText(string(out)), nil, nil +} + +func uiGetMilestones(ctx context.Context, deps ToolDependencies, args map[string]any, owner string) (*mcp.CallToolResult, any, error) { + repo, err := RequiredParam[string](args, "repo") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + client, err := deps.GetClient(ctx) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil + } + + opts := &github.MilestoneListOptions{ + State: "open", + ListOptions: github.ListOptions{PerPage: 100}, + } + + var allMilestones []*github.Milestone + for { + milestones, resp, err := client.Issues.ListMilestones(ctx, owner, repo, opts) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list milestones", resp, err), nil, nil + } + allMilestones = append(allMilestones, milestones...) + if resp != nil && resp.Body != nil { + _ = resp.Body.Close() + } + if resp.NextPage == 0 { + break + } + opts.Page = resp.NextPage + } + + result := make([]map[string]any, len(allMilestones)) + for i, m := range allMilestones { + dueOn := "" + if m.DueOn != nil { + dueOn = m.GetDueOn().Format("2006-01-02") + } + result[i] = map[string]any{ + "number": m.GetNumber(), + "title": m.GetTitle(), + "description": m.GetDescription(), + "state": m.GetState(), + "open_issues": m.GetOpenIssues(), + "due_on": dueOn, + } + } + + out, err := json.Marshal(map[string]any{ + "milestones": result, + "totalCount": len(result), + }) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to marshal milestones", err), nil, nil + } + + return utils.NewToolResultText(string(out)), nil, nil +} + +func uiGetIssueTypes(ctx context.Context, deps ToolDependencies, owner string) (*mcp.CallToolResult, any, error) { + client, err := deps.GetClient(ctx) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil + } + + issueTypes, resp, err := client.Organizations.ListIssueTypes(ctx, owner) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list issue types", resp, err), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, err := io.ReadAll(resp.Body) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to read response body", err), nil, nil + } + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to list issue types", resp, body), nil, nil + } + + r, err := json.Marshal(issueTypes) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to marshal issue types", err), nil, nil + } + + return utils.NewToolResultText(string(r)), nil, nil +} + +func uiGetBranches(ctx context.Context, deps ToolDependencies, args map[string]any, owner string) (*mcp.CallToolResult, any, error) { + repo, err := RequiredParam[string](args, "repo") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + client, err := deps.GetClient(ctx) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil + } + + opts := &github.BranchListOptions{ + ListOptions: github.ListOptions{PerPage: 100}, + } + + var allBranches []*github.Branch + for { + branches, resp, err := client.Repositories.ListBranches(ctx, owner, repo, opts) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list branches", resp, err), nil, nil + } + allBranches = append(allBranches, branches...) + if resp != nil && resp.Body != nil { + _ = resp.Body.Close() + } + if resp.NextPage == 0 { + break + } + opts.Page = resp.NextPage + } + + minimalBranches := make([]MinimalBranch, 0, len(allBranches)) + for _, branch := range allBranches { + minimalBranches = append(minimalBranches, convertToMinimalBranch(branch)) + } + + r, err := json.Marshal(map[string]any{ + "branches": minimalBranches, + "totalCount": len(minimalBranches), + }) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil + } + + return utils.NewToolResultText(string(r)), nil, nil +} + +func uiGetIssueFields(ctx context.Context, deps ToolDependencies, args map[string]any, owner string) (*mcp.CallToolResult, any, error) { + repo, err := RequiredParam[string](args, "repo") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + if !deps.IsFeatureEnabled(ctx, FeatureFlagIssueFields) { + return marshalUIGetIssueFields(nil) + } + + gqlClient, err := deps.GetGQLClient(ctx) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to get GitHub GraphQL client", err), nil, nil + } + + fields, err := fetchIssueFields(ctx, gqlClient, owner, repo) + if err != nil { + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to list issue fields", err), nil, nil + } + + return marshalUIGetIssueFields(fields) +} + +func marshalUIGetIssueFields(fields []IssueField) (*mcp.CallToolResult, any, error) { + resultFields := make([]map[string]any, 0, len(fields)) + for _, field := range fields { + if !uiSupportedIssueFieldDataType(field.DataType) { + continue + } + + fieldResult := map[string]any{ + "id": field.ID, + "name": field.Name, + "data_type": field.DataType, + "description": field.Description, + } + + if field.DataType == "single_select" { + fieldOptions := append([]IssueSingleSelectFieldOption(nil), field.Options...) + sort.SliceStable(fieldOptions, func(i, j int) bool { + left, leftOK := issueFieldOptionPriority(fieldOptions[i]) + right, rightOK := issueFieldOptionPriority(fieldOptions[j]) + if leftOK != rightOK { + return leftOK + } + return left < right + }) + + options := make([]map[string]string, 0, len(fieldOptions)) + for _, option := range fieldOptions { + options = append(options, map[string]string{ + "name": option.Name, + "description": option.Description, + "color": option.Color, + }) + } + fieldResult["options"] = options + } + + resultFields = append(resultFields, fieldResult) + } + + r, err := json.Marshal(map[string]any{ + "fields": resultFields, + "totalCount": len(resultFields), + }) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to marshal issue fields", err), nil, nil + } + + return utils.NewToolResultText(string(r)), nil, nil +} + +func uiSupportedIssueFieldDataType(dataType string) bool { + switch dataType { + case "text", "number", "date", "single_select": + return true + default: + return false + } +} + +func issueFieldOptionPriority(option IssueSingleSelectFieldOption) (int, bool) { + if option.Priority == nil { + return 0, false + } + return *option.Priority, true +} + +func uiGetReviewers(ctx context.Context, deps ToolDependencies, args map[string]any, owner string) (*mcp.CallToolResult, any, error) { + repo, err := RequiredParam[string](args, "repo") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + client, err := deps.GetClient(ctx) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil + } + + collaboratorOpts := &github.ListCollaboratorsOptions{ + Affiliation: "all", + ListOptions: github.ListOptions{PerPage: 100}, + } + var allCollaborators []*github.User + for { + collaborators, resp, err := client.Repositories.ListCollaborators(ctx, owner, repo, collaboratorOpts) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list reviewers", resp, err), nil, nil + } + allCollaborators = append(allCollaborators, collaborators...) + if resp != nil && resp.Body != nil { + _ = resp.Body.Close() + } + if resp.NextPage == 0 { + break + } + collaboratorOpts.Page = resp.NextPage + } + + teamOpts := &github.ListOptions{PerPage: 100} + var allTeams []*github.Team + for { + teams, resp, err := client.Repositories.ListTeams(ctx, owner, repo, teamOpts) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list reviewer teams", resp, err), nil, nil + } + allTeams = append(allTeams, teams...) + if resp != nil && resp.Body != nil { + _ = resp.Body.Close() + } + if resp.NextPage == 0 { + break + } + teamOpts.Page = resp.NextPage + } + + users := make([]map[string]string, 0, len(allCollaborators)) + for _, user := range allCollaborators { + login := user.GetLogin() + if user.GetType() == "Bot" || strings.HasSuffix(login, "[bot]") { + continue + } + users = append(users, map[string]string{ + "login": login, + "avatar_url": user.GetAvatarURL(), + }) + } + + teams := make([]map[string]string, len(allTeams)) + for i, team := range allTeams { + teams[i] = map[string]string{ + "slug": team.GetSlug(), + "name": team.GetName(), + "org": owner, + } + } + + r, err := json.Marshal(map[string]any{ + "users": users, + "teams": teams, + "totalCount": len(users) + len(teams), + }) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to marshal reviewers", err), nil, nil + } + + return utils.NewToolResultText(string(r)), nil, nil +} diff --git a/pkg/github/ui_tools_test.go b/pkg/github/ui_tools_test.go new file mode 100644 index 0000000000..2fded6b20e --- /dev/null +++ b/pkg/github/ui_tools_test.go @@ -0,0 +1,414 @@ +package github + +import ( + "context" + "encoding/json" + "net/http" + "testing" + "time" + + "github.com/github/github-mcp-server/internal/githubv4mock" + "github.com/github/github-mcp-server/internal/toolsnaps" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/google/go-github/v87/github" + "github.com/google/jsonschema-go/jsonschema" + "github.com/shurcooL/githubv4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_UIGet(t *testing.T) { + // Verify tool definition + serverTool := UIGet(translations.NullTranslationHelper) + tool := serverTool.Tool + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "ui_get", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "method") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "owner") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "repo") + assert.ElementsMatch(t, tool.InputSchema.(*jsonschema.Schema).Required, []string{"method", "owner"}) + assert.True(t, tool.Annotations.ReadOnlyHint, "ui_get should be read-only") + assert.Equal(t, MCPAppsFeatureFlag, serverTool.FeatureFlagEnable, "ui_get should be gated on the MCP Apps feature flag") + + // ui_get must be app-only so the host hides it from the agent's tool list + // while keeping it callable by the views (MCP Apps 2026-01-26 spec). + ui, ok := tool.Meta["ui"].(map[string]any) + require.True(t, ok, "ui_get should declare _meta.ui") + assert.Equal(t, []string{"app"}, ui["visibility"], "ui_get should be app-only") + + // Setup mock data + mockAssignees := []*github.User{ + {Login: github.Ptr("user1"), AvatarURL: github.Ptr("https://avatars.githubusercontent.com/u/1")}, + {Login: github.Ptr("user2"), AvatarURL: github.Ptr("https://avatars.githubusercontent.com/u/2")}, + } + + mockBranches := []*github.Branch{ + {Name: github.Ptr("main"), Protected: github.Ptr(true)}, + {Name: github.Ptr("feature"), Protected: github.Ptr(false)}, + } + + dueDate := time.Date(2026, 1, 31, 0, 0, 0, 0, time.UTC) + mockMilestones := []*github.Milestone{ + {Number: github.Ptr(1), Title: github.Ptr("with due date"), DueOn: &github.Timestamp{Time: dueDate}}, + {Number: github.Ptr(2), Title: github.Ptr("no due date")}, + } + + mockIssueTypes := []*github.IssueType{ + {Name: github.Ptr("Bug")}, + {Name: github.Ptr("Feature")}, + } + + mockReviewers := []*github.User{ + {Login: github.Ptr("octocat"), AvatarURL: github.Ptr("https://avatars.githubusercontent.com/u/583231")}, + {Login: github.Ptr("dependabot[bot]"), AvatarURL: github.Ptr("https://avatars.githubusercontent.com/in/29110")}, + {Login: github.Ptr("github-actions"), Type: github.Ptr("Bot")}, + } + + mockReviewerTeams := []*github.Team{ + {Slug: github.Ptr("docs"), Name: github.Ptr("Docs")}, + } + + tests := []struct { + name string + mockedClient *http.Client + mockedGQLClient *http.Client + requestArgs map[string]any + expectError bool + expectedErrMsg string + validateResult func(t *testing.T, responseText string) + }{ + { + name: "successful assignees fetch", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "GET /repos/owner/repo/assignees": mockResponse(t, http.StatusOK, mockAssignees), + }), + requestArgs: map[string]any{ + "method": "assignees", + "owner": "owner", + "repo": "repo", + }, + expectError: false, + validateResult: func(t *testing.T, responseText string) { + var response map[string]any + require.NoError(t, json.Unmarshal([]byte(responseText), &response)) + assert.Contains(t, response, "assignees") + assert.Contains(t, response, "totalCount") + }, + }, + { + name: "successful branches fetch", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "GET /repos/owner/repo/branches": mockResponse(t, http.StatusOK, mockBranches), + }), + requestArgs: map[string]any{ + "method": "branches", + "owner": "owner", + "repo": "repo", + }, + expectError: false, + validateResult: func(t *testing.T, responseText string) { + var response map[string]any + require.NoError(t, json.Unmarshal([]byte(responseText), &response)) + assert.Contains(t, response, "branches") + assert.Contains(t, response, "totalCount") + }, + }, + { + name: "successful milestones fetch", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "GET /repos/owner/repo/milestones": mockResponse(t, http.StatusOK, mockMilestones), + }), + requestArgs: map[string]any{ + "method": "milestones", + "owner": "owner", + "repo": "repo", + }, + expectError: false, + validateResult: func(t *testing.T, responseText string) { + var response map[string]any + require.NoError(t, json.Unmarshal([]byte(responseText), &response)) + milestones, ok := response["milestones"].([]any) + require.True(t, ok, "milestones should be a list") + require.Len(t, milestones, 2) + first := milestones[0].(map[string]any) + assert.Equal(t, "2026-01-31", first["due_on"], "milestone with a due date should be formatted") + second := milestones[1].(map[string]any) + assert.Equal(t, "", second["due_on"], "milestone without a due date should be empty, not zero time") + }, + }, + { + name: "successful issue_types fetch", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "GET /orgs/owner/issue-types": mockResponse(t, http.StatusOK, mockIssueTypes), + }), + requestArgs: map[string]any{ + "method": "issue_types", + "owner": "owner", + }, + expectError: false, + validateResult: func(t *testing.T, responseText string) { + var issueTypes []map[string]any + require.NoError(t, json.Unmarshal([]byte(responseText), &issueTypes)) + require.Len(t, issueTypes, 2) + assert.Equal(t, "Bug", issueTypes[0]["name"]) + }, + }, + { + name: "issue_types API error returns response context", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "GET /orgs/owner/issue-types": mockResponse(t, http.StatusForbidden, map[string]string{"message": "Forbidden"}), + }), + requestArgs: map[string]any{ + "method": "issue_types", + "owner": "owner", + }, + expectError: true, + expectedErrMsg: "failed to list issue types", + }, + { + name: "successful labels fetch", + mockedGQLClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + Labels struct { + Nodes []struct { + ID githubv4.ID + Name githubv4.String + Color githubv4.String + Description githubv4.String + } + TotalCount githubv4.Int + PageInfo struct { + HasNextPage githubv4.Boolean + EndCursor githubv4.String + } + } `graphql:"labels(first: 100, after: $cursor)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "repo": githubv4.String("repo"), + "cursor": (*githubv4.String)(nil), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "labels": map[string]any{ + "nodes": []any{ + map[string]any{ + "id": githubv4.ID("label-1"), + "name": githubv4.String("bug"), + "color": githubv4.String("d73a4a"), + "description": githubv4.String("Something isn't working"), + }, + }, + "totalCount": githubv4.Int(1), + "pageInfo": map[string]any{ + "hasNextPage": githubv4.Boolean(false), + "endCursor": githubv4.String(""), + }, + }, + }, + }), + ), + ), + requestArgs: map[string]any{ + "method": "labels", + "owner": "owner", + "repo": "repo", + }, + expectError: false, + validateResult: func(t *testing.T, responseText string) { + var response map[string]any + require.NoError(t, json.Unmarshal([]byte(responseText), &response)) + labels, ok := response["labels"].([]any) + require.True(t, ok, "labels should be a list") + require.Len(t, labels, 1) + assert.Equal(t, "bug", labels[0].(map[string]any)["name"]) + assert.Equal(t, float64(1), response["totalCount"]) + }, + }, + { + name: "issue_fields feature disabled returns empty list", + requestArgs: map[string]any{ + "method": "issue_fields", + "owner": "owner", + "repo": "repo", + }, + expectError: false, + validateResult: func(t *testing.T, responseText string) { + var response map[string]any + require.NoError(t, json.Unmarshal([]byte(responseText), &response)) + fields, ok := response["fields"].([]any) + require.True(t, ok, "fields should be a list") + assert.Empty(t, fields) + assert.Equal(t, float64(0), response["totalCount"]) + }, + }, + { + name: "successful reviewers fetch", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "GET /repos/owner/repo/collaborators": mockResponse(t, http.StatusOK, mockReviewers), + "GET /repos/owner/repo/teams": mockResponse(t, http.StatusOK, mockReviewerTeams), + }), + requestArgs: map[string]any{ + "method": "reviewers", + "owner": "owner", + "repo": "repo", + }, + expectError: false, + validateResult: func(t *testing.T, responseText string) { + var response map[string]any + require.NoError(t, json.Unmarshal([]byte(responseText), &response)) + users, ok := response["users"].([]any) + require.True(t, ok, "users should be a list") + require.Len(t, users, 1) + assert.Equal(t, "octocat", users[0].(map[string]any)["login"]) + teams, ok := response["teams"].([]any) + require.True(t, ok, "teams should be a list") + require.Len(t, teams, 1) + assert.Equal(t, "docs", teams[0].(map[string]any)["slug"]) + assert.Equal(t, "owner", teams[0].(map[string]any)["org"]) + assert.Equal(t, float64(2), response["totalCount"]) + }, + }, + { + name: "missing method parameter", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + }, + expectError: true, + expectedErrMsg: "missing required parameter: method", + }, + { + name: "missing owner parameter", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), + requestArgs: map[string]any{ + "method": "assignees", + "repo": "repo", + }, + expectError: true, + expectedErrMsg: "missing required parameter: owner", + }, + { + name: "missing repo parameter for assignees", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), + requestArgs: map[string]any{ + "method": "assignees", + "owner": "owner", + }, + expectError: true, + expectedErrMsg: "missing required parameter: repo", + }, + { + name: "unknown method", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), + requestArgs: map[string]any{ + "method": "unknown", + "owner": "owner", + "repo": "repo", + }, + expectError: true, + expectedErrMsg: "unknown method: unknown", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup deps with REST and/or GraphQL mocks + deps := BaseDeps{} + if tc.mockedClient != nil { + client, err := github.NewClient(github.WithHTTPClient(tc.mockedClient)) + require.NoError(t, err) + deps.Client = client + } + if tc.mockedGQLClient != nil { + deps.GQLClient = githubv4.NewClient(tc.mockedGQLClient) + } + handler := serverTool.Handler(deps) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + // Verify results + if tc.expectError { + if err != nil { + assert.Contains(t, err.Error(), tc.expectedErrMsg) + return + } + require.NotNil(t, result) + require.True(t, result.IsError) + errorContent := getErrorResult(t, result) + assert.Contains(t, errorContent.Text, tc.expectedErrMsg) + return + } + + require.NoError(t, err) + require.NotNil(t, result) + require.False(t, result.IsError) + textContent := getTextResult(t, result) + + if tc.validateResult != nil { + tc.validateResult(t, textContent.Text) + } + }) + } +} + +func Test_marshalUIGetIssueFields_TrimsForUI(t *testing.T) { + priorityLow := 1 + priorityHigh := 2 + result, _, err := marshalUIGetIssueFields([]IssueField{ + { + ID: "field-1", + DatabaseID: 123, + Name: "Priority", + Description: "How urgent this is", + DataType: "single_select", + Visibility: "public", + Options: []IssueSingleSelectFieldOption{ + {ID: "option-2", Name: "High", Description: "High priority", Color: "red", Priority: &priorityHigh}, + {ID: "option-1", Name: "Low", Description: "Low priority", Color: "blue", Priority: &priorityLow}, + {ID: "option-3", Name: "No priority", Description: "No priority set", Color: "gray"}, + }, + }, + { + ID: "field-2", + Name: "Unsupported", + DataType: "iteration", + }, + { + ID: "field-3", + Name: "Notes", + DataType: "text", + }, + }) + require.NoError(t, err) + + var response map[string]any + require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &response)) + fields := response["fields"].([]any) + require.Len(t, fields, 2) + assert.Equal(t, float64(2), response["totalCount"]) + + singleSelectField := fields[0].(map[string]any) + assert.NotContains(t, singleSelectField, "full_database_id") + assert.NotContains(t, singleSelectField, "visibility") + options := singleSelectField["options"].([]any) + require.Len(t, options, 3) + assert.Equal(t, "Low", options[0].(map[string]any)["name"]) + assert.Equal(t, "High", options[1].(map[string]any)["name"]) + assert.Equal(t, "No priority", options[2].(map[string]any)["name"]) + assert.NotContains(t, options[0].(map[string]any), "id") + assert.NotContains(t, options[0].(map[string]any), "priority") + + textField := fields[1].(map[string]any) + assert.NotContains(t, textField, "options") +} diff --git a/pkg/http/server.go b/pkg/http/server.go index 3c9d7679e4..36d3e111bc 100644 --- a/pkg/http/server.go +++ b/pkg/http/server.go @@ -5,9 +5,11 @@ import ( "fmt" "io" "log/slog" + "net" "net/http" "os" "os/signal" + "strconv" "syscall" "time" @@ -32,9 +34,13 @@ type ServerConfig struct { // GitHub Host to target for API requests (e.g. github.com or github.enterprise.com) Host string - // Port to listen on (default: 8082) + // Port to listen on (default: 8082). Port int + // ListenHost is the host the HTTP server binds to (e.g. "127.0.0.1"). + // When empty, the server binds to all interfaces. Combined with Port. + ListenHost string + // BaseURL is the publicly accessible URL of this server for OAuth resource metadata. // If not set, the server will derive the URL from incoming request headers. BaseURL string @@ -192,7 +198,7 @@ func RunHTTPServer(cfg ServerConfig) error { }) logger.Info("OAuth protected resource endpoints registered", "baseURL", cfg.BaseURL) - addr := fmt.Sprintf(":%d", cfg.Port) + addr := resolveListenAddress(cfg.ListenHost, cfg.Port) httpSvr := http.Server{ Addr: addr, Handler: r, @@ -223,6 +229,16 @@ func RunHTTPServer(cfg ServerConfig) error { return nil } +// resolveListenAddress returns the address string passed to http.Server. +// When host is empty the server binds to all interfaces on the given port; +// otherwise host and port are joined into a single address. +func resolveListenAddress(host string, port int) string { + if host == "" { + return fmt.Sprintf(":%d", port) + } + return net.JoinHostPort(host, strconv.Itoa(port)) +} + func initGlobalToolScopeMap(t translations.TranslationHelperFunc) error { // Build inventory with all tools to extract scope information inv, err := inventory.NewBuilder(). diff --git a/pkg/http/server_test.go b/pkg/http/server_test.go index 1804134651..b509876d9e 100644 --- a/pkg/http/server_test.go +++ b/pkg/http/server_test.go @@ -125,6 +125,47 @@ func TestCreateHTTPFeatureChecker(t *testing.T) { } } +func TestResolveListenAddress(t *testing.T) { + tests := []struct { + name string + host string + port int + want string + }{ + { + name: "empty host falls back to :port", + host: "", + port: 8082, + want: ":8082", + }, + { + name: "ipv4 host is joined with port", + host: "127.0.0.1", + port: 9090, + want: "127.0.0.1:9090", + }, + { + name: "ipv6 host is bracketed and joined with port", + host: "::1", + port: 9090, + want: "[::1]:9090", + }, + { + name: "hostname is joined with port", + host: "localhost", + port: 8082, + want: "localhost:8082", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := resolveListenAddress(tt.host, tt.port) + assert.Equal(t, tt.want, got) + }) + } +} + func TestHeaderAllowedFeatureFlagsMatchesAllowed(t *testing.T) { // Ensure HeaderAllowedFeatureFlags delegates to AllowedFeatureFlags allowed := github.HeaderAllowedFeatureFlags() diff --git a/pkg/ifc/ifc.go b/pkg/ifc/ifc.go index fefe542e3d..f23383ce73 100644 --- a/pkg/ifc/ifc.go +++ b/pkg/ifc/ifc.go @@ -76,10 +76,23 @@ func LabelGetMe() SecurityLabel { // LabelListIssues returns the IFC label for a list_issues result. // Public repositories are universally readable; private repositories are // restricted to their collaborators (resolved client-side from the marker). -// Issue contents are attacker-controllable, so integrity is always untrusted. +// Public repository issue contents are attacker-controllable, while private +// repository issues are treated as trusted collaborator-authored data. func LabelListIssues(isPrivate bool) SecurityLabel { if isPrivate { - return PrivateUntrusted() + return PrivateTrusted() + } + return PublicUntrusted() +} + +// LabelRepoUserContent returns the IFC label for user-authored content scoped +// to a repository when that tool has not opted into a more specific integrity +// policy. Public repository content is untrusted because it may be authored by +// outside contributors. Private repository content is trusted because users who +// can read it are trusted collaborators. +func LabelRepoUserContent(isPrivate bool) SecurityLabel { + if isPrivate { + return PrivateTrusted() } return PublicUntrusted() } @@ -99,11 +112,12 @@ func LabelGetFileContents(isPrivate bool) SecurityLabel { // result, joining per-repository labels across all matched repositories. // Used by both search_issues and search_repositories. // -// Integrity is always untrusted because results expose user-authored content. -// -// Confidentiality follows the IFC meet (greatest lower bound): if any matched -// repository is private the joined label is private; otherwise public. The -// reader set is opaque (the "private" marker); the client engine resolves +// Public-only results are untrusted and public. All-private results are trusted +// and private because private repository content is treated as trusted +// collaborator-authored data. Mixed public/private results are untrusted and +// private: the public items keep the joined payload's integrity untrusted, +// while the private items keep the joined payload's confidentiality private. +// The reader set is opaque (the "private" marker); the client engine resolves // concrete readers on demand at egress decision time. // // An empty result set is treated as public-untrusted (no repository data is @@ -119,12 +133,22 @@ func LabelGetFileContents(isPrivate bool) SecurityLabel { // until then they would invite unsafe declassification of a "public" item that // actually arrived alongside private data. func LabelSearchIssues(repoVisibilities []bool) SecurityLabel { + var anyPrivate, anyPublic bool for _, isPrivate := range repoVisibilities { if isPrivate { - return PrivateUntrusted() + anyPrivate = true + } else { + anyPublic = true } } - return PublicUntrusted() + switch { + case anyPrivate && anyPublic: + return PrivateUntrusted() + case anyPrivate: + return PrivateTrusted() + default: + return PublicUntrusted() + } } // LabelRepoMetadata returns the IFC label for structural repository metadata @@ -261,47 +285,75 @@ func LabelRepositorySecurityAdvisory(isPrivate bool, allPublished bool) Security // LabelGist returns the IFC label for gist content. // // Integrity is untrusted: gist contents are arbitrary user-authored text. -// Confidentiality derives from the gist's own visibility rather than any -// repository — public gists are universally readable, while secret gists are -// restricted to those who hold the gist URL (modeled with the opaque "private" -// marker). -func LabelGist(isPublic bool) SecurityLabel { - if isPublic { - return PublicUntrusted() - } - return PrivateUntrusted() +// Confidentiality is public because secret gists are URL-accessible and cannot +// be modeled as private to a GitHub reader set. +func LabelGist() SecurityLabel { + return PublicUntrusted() } // LabelGistList returns the IFC label for a list of gists belonging to a user, // joining the per-gist confidentiality across the result set. // -// Integrity is untrusted (user-authored content). Confidentiality follows the -// IFC meet: if any gist in the result is secret the joined label is private; -// otherwise public. An empty result is treated as public-untrusted. +// Integrity is untrusted (user-authored content). Confidentiality is public +// because even secret gists are URL-accessible. // // See LabelSearchIssues for why list results carry a single joined label // rather than one label per item. -func LabelGistList(gistVisibilities []bool) SecurityLabel { - for _, isPublic := range gistVisibilities { - if !isPublic { - return PrivateUntrusted() - } +func LabelGistList() SecurityLabel { + return PublicUntrusted() +} + +// LabelProject returns the IFC label for GitHub Project metadata (Projects v2), +// such as get_project results and project field definitions. +// +// Public project metadata can contain public user-authored text, so it is +// untrusted. Private project metadata is treated as trusted +// collaborator-controlled data. +// +// Confidentiality derives from the project's own privacy — private projects +// restrict the reader set, while public projects are universally readable. +func LabelProject(isPrivate bool) SecurityLabel { + if isPrivate { + return PrivateTrusted() } return PublicUntrusted() } -// LabelProject returns the IFC label for a GitHub Project (Projects v2) and its -// items, status updates, and field definitions. +// LabelProjectList returns the IFC label for a list_projects result, joining +// the per-project labels across every returned project. // -// Integrity is untrusted: project titles, item content, and status update -// bodies are user-authored free text. Confidentiality derives from the -// project's own public flag — public projects are universally readable, while -// private projects restrict the reader set. -func LabelProject(isPublic bool) SecurityLabel { - if isPublic { +// Public-only results are untrusted and public. All-private results are trusted +// and private. Mixed public/private results are untrusted and private: public +// items keep the joined payload's integrity untrusted, while private items keep +// the joined payload's confidentiality private. +func LabelProjectList(projectVisibilities []bool) SecurityLabel { + var anyPrivate, anyPublic bool + for _, isPrivate := range projectVisibilities { + if isPrivate { + anyPrivate = true + } else { + anyPublic = true + } + } + switch { + case anyPrivate && anyPublic: + return PrivateUntrusted() + case anyPrivate: + return PrivateTrusted() + default: return PublicUntrusted() } - return PrivateUntrusted() +} + +// LabelProjectContent returns the IFC label for project results that can include +// item content, field values, or status update bodies. These can aggregate +// content from a variety of sources, so integrity remains untrusted even when +// the project is private. +func LabelProjectContent(isPrivate bool) SecurityLabel { + if isPrivate { + return PrivateUntrusted() + } + return PublicUntrusted() } // LabelTeam returns the IFC label for organization team membership data diff --git a/pkg/ifc/ifc_test.go b/pkg/ifc/ifc_test.go index 90788a8cb7..f4b25c1876 100644 --- a/pkg/ifc/ifc_test.go +++ b/pkg/ifc/ifc_test.go @@ -6,36 +6,78 @@ import ( "github.com/stretchr/testify/assert" ) +func TestLabelListIssues(t *testing.T) { + t.Parallel() + + t.Run("public repo issues are untrusted and public", func(t *testing.T) { + t.Parallel() + label := LabelListIssues(false) + assert.Equal(t, IntegrityUntrusted, label.Integrity) + assert.Equal(t, ConfidentialityPublic, label.Confidentiality) + }) + + t.Run("private repo issues are trusted and private", func(t *testing.T) { + t.Parallel() + label := LabelListIssues(true) + assert.Equal(t, IntegrityTrusted, label.Integrity) + assert.Equal(t, ConfidentialityPrivate, label.Confidentiality) + }) +} + +func TestLabelRepoUserContent(t *testing.T) { + t.Parallel() + + t.Run("public repo user content is untrusted and public", func(t *testing.T) { + t.Parallel() + label := LabelRepoUserContent(false) + assert.Equal(t, IntegrityUntrusted, label.Integrity) + assert.Equal(t, ConfidentialityPublic, label.Confidentiality) + }) + + t.Run("private repo user content is trusted and private", func(t *testing.T) { + t.Parallel() + label := LabelRepoUserContent(true) + assert.Equal(t, IntegrityTrusted, label.Integrity) + assert.Equal(t, ConfidentialityPrivate, label.Confidentiality) + }) +} + func TestLabelSearchIssues(t *testing.T) { t.Parallel() tests := []struct { name string - visibilities []bool + visibilities []bool // true == private + wantIntegrity Integrity wantConfidential Confidentiality }{ { name: "empty result is treated as public", + wantIntegrity: IntegrityUntrusted, wantConfidential: ConfidentialityPublic, }, { name: "single public repo", visibilities: []bool{false}, + wantIntegrity: IntegrityUntrusted, wantConfidential: ConfidentialityPublic, }, { name: "all public repos stay public", visibilities: []bool{false, false, false}, + wantIntegrity: IntegrityUntrusted, wantConfidential: ConfidentialityPublic, }, { - name: "any private match flips to private", + name: "mixed public and private repos become untrusted private", visibilities: []bool{false, true, false}, + wantIntegrity: IntegrityUntrusted, wantConfidential: ConfidentialityPrivate, }, { - name: "all private repos stay private", + name: "all private repos stay trusted private", visibilities: []bool{true, true}, + wantIntegrity: IntegrityTrusted, wantConfidential: ConfidentialityPrivate, }, } @@ -44,7 +86,7 @@ func TestLabelSearchIssues(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() label := LabelSearchIssues(tc.visibilities) - assert.Equal(t, IntegrityUntrusted, label.Integrity) + assert.Equal(t, tc.wantIntegrity, label.Integrity) assert.Equal(t, tc.wantConfidential, label.Confidentiality) }) } @@ -208,44 +250,75 @@ func TestLabelGist(t *testing.T) { t.Run("public gist is untrusted and public", func(t *testing.T) { t.Parallel() - label := LabelGist(true) + label := LabelGist() assert.Equal(t, IntegrityUntrusted, label.Integrity) assert.Equal(t, ConfidentialityPublic, label.Confidentiality) }) - t.Run("secret gist is untrusted and private", func(t *testing.T) { + t.Run("secret gist is untrusted and public", func(t *testing.T) { t.Parallel() - label := LabelGist(false) + label := LabelGist() assert.Equal(t, IntegrityUntrusted, label.Integrity) - assert.Equal(t, ConfidentialityPrivate, label.Confidentiality) + assert.Equal(t, ConfidentialityPublic, label.Confidentiality) }) } func TestLabelGistList(t *testing.T) { t.Parallel() + label := LabelGistList() + assert.Equal(t, IntegrityUntrusted, label.Integrity) + assert.Equal(t, ConfidentialityPublic, label.Confidentiality) +} + +func TestLabelProject(t *testing.T) { + t.Parallel() + + t.Run("public project is untrusted and public", func(t *testing.T) { + t.Parallel() + label := LabelProject(false) + assert.Equal(t, IntegrityUntrusted, label.Integrity) + assert.Equal(t, ConfidentialityPublic, label.Confidentiality) + }) + + t.Run("private project metadata is trusted and private", func(t *testing.T) { + t.Parallel() + label := LabelProject(true) + assert.Equal(t, IntegrityTrusted, label.Integrity) + assert.Equal(t, ConfidentialityPrivate, label.Confidentiality) + }) +} + +func TestLabelProjectList(t *testing.T) { + t.Parallel() + tests := []struct { name string - visibilities []bool // true == public + visibilities []bool // true == private + wantIntegrity Integrity wantConfidential Confidentiality }{ { name: "empty result is treated as public", + wantIntegrity: IntegrityUntrusted, wantConfidential: ConfidentialityPublic, }, { - name: "all public gists stay public", - visibilities: []bool{true, true}, + name: "all public projects stay public", + visibilities: []bool{false, false}, + wantIntegrity: IntegrityUntrusted, wantConfidential: ConfidentialityPublic, }, { - name: "any secret gist flips to private", - visibilities: []bool{true, false, true}, + name: "mixed public and private projects become untrusted private", + visibilities: []bool{false, true}, + wantIntegrity: IntegrityUntrusted, wantConfidential: ConfidentialityPrivate, }, { - name: "all secret gists stay private", - visibilities: []bool{false, false}, + name: "all private projects stay trusted private", + visibilities: []bool{true, true}, + wantIntegrity: IntegrityTrusted, wantConfidential: ConfidentialityPrivate, }, } @@ -253,26 +326,26 @@ func TestLabelGistList(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { t.Parallel() - label := LabelGistList(tc.visibilities) - assert.Equal(t, IntegrityUntrusted, label.Integrity) + label := LabelProjectList(tc.visibilities) + assert.Equal(t, tc.wantIntegrity, label.Integrity) assert.Equal(t, tc.wantConfidential, label.Confidentiality) }) } } -func TestLabelProject(t *testing.T) { +func TestLabelProjectContent(t *testing.T) { t.Parallel() - t.Run("public project is untrusted and public", func(t *testing.T) { + t.Run("public project content is untrusted and public", func(t *testing.T) { t.Parallel() - label := LabelProject(true) + label := LabelProjectContent(false) assert.Equal(t, IntegrityUntrusted, label.Integrity) assert.Equal(t, ConfidentialityPublic, label.Confidentiality) }) - t.Run("private project is untrusted and private", func(t *testing.T) { + t.Run("private project content is untrusted and private", func(t *testing.T) { t.Parallel() - label := LabelProject(false) + label := LabelProjectContent(true) assert.Equal(t, IntegrityUntrusted, label.Integrity) assert.Equal(t, ConfidentialityPrivate, label.Confidentiality) }) diff --git a/pkg/inventory/builder.go b/pkg/inventory/builder.go index 9ecaca1f57..c8a9c21bc9 100644 --- a/pkg/inventory/builder.go +++ b/pkg/inventory/builder.go @@ -7,6 +7,8 @@ import ( "maps" "slices" "strings" + + "github.com/google/jsonschema-go/jsonschema" ) var ( @@ -406,6 +408,97 @@ func stripMCPAppsMetadata(tools []ServerTool) []ServerTool { return result } +// uiOnlySchemaProperties lists input-schema property names that should only +// be visible to clients that advertise MCP Apps UI support. They live on the +// static schema (so toolsnaps and the feature-flag / insiders docs document +// the full UI-capable surface; the main README renders the stripped +// non-UI schema) and are stripped per-request when the same gate that hides +// _meta.ui is true. +var uiOnlySchemaProperties = []string{ + "show_ui", // explicit "render the MCP App form" toggle on form-backed write tools +} + +// ConditionalSchemaPropertyDescriptions returns a map of schema property name +// to a human-readable description of the condition under which the property +// is visible to clients. The doc generator uses this to annotate conditional +// parameters so readers can see at a glance which fields are not always +// available. This is the single source of truth for the conditional-property +// surface — entries here must correspond to a strip rule in +// ToolsForRegistration. +func ConditionalSchemaPropertyDescriptions() map[string]string { + const uiOnlyCondition = "visible when remote_mcp_ui_apps is enabled unless the client explicitly indicates it does not support io.modelcontextprotocol/ui" + out := make(map[string]string, len(uiOnlySchemaProperties)) + for _, name := range uiOnlySchemaProperties { + out[name] = uiOnlyCondition + } + return out +} + +// stripUIOnlySchemaProperties removes UI-capability-gated input-schema +// properties (currently just "show_ui") from each tool's static schema. +// Tools whose InputSchema is not a *jsonschema.Schema (e.g. json.RawMessage) +// are passed through untouched — no such tool currently declares a gated +// property, and inferring intent from an opaque schema is not safe. +// Tools without any gated property are returned as-is so we only allocate +// when a change is actually made (mirrors the stripMetaKeys pattern). +func stripUIOnlySchemaProperties(tools []ServerTool) []ServerTool { + result := make([]ServerTool, 0, len(tools)) + for _, tool := range tools { + if stripped := stripSchemaProperties(tool, uiOnlySchemaProperties); stripped != nil { + result = append(result, *stripped) + } else { + result = append(result, tool) + } + } + return result +} + +// stripSchemaProperties removes the named keys from tool.Tool.InputSchema's +// Properties map (and Required list, if present) and returns a modified copy. +// Returns nil when the schema is not a *jsonschema.Schema or no listed key +// is present, signalling no change. +func stripSchemaProperties(tool ServerTool, keys []string) *ServerTool { + if tool.Tool.InputSchema == nil || len(keys) == 0 { + return nil + } + schema, ok := tool.Tool.InputSchema.(*jsonschema.Schema) + if !ok || schema == nil || len(schema.Properties) == 0 { + return nil + } + + hasKey := false + for _, key := range keys { + if _, exists := schema.Properties[key]; exists { + hasKey = true + break + } + } + if !hasKey { + return nil + } + + toolCopy := tool + schemaCopy := *schema + newProps := make(map[string]*jsonschema.Schema, len(schema.Properties)) + for k, v := range schema.Properties { + if !slices.Contains(keys, k) { + newProps[k] = v + } + } + schemaCopy.Properties = newProps + if len(schemaCopy.Required) > 0 { + newRequired := make([]string, 0, len(schemaCopy.Required)) + for _, r := range schemaCopy.Required { + if !slices.Contains(keys, r) { + newRequired = append(newRequired, r) + } + } + schemaCopy.Required = newRequired + } + toolCopy.Tool.InputSchema = &schemaCopy + return &toolCopy +} + // stripMetaKeys removes the specified Meta keys from a single tool. // Returns a modified copy if changes were made, nil otherwise. func stripMetaKeys(tool ServerTool, keys []string) *ServerTool { diff --git a/pkg/inventory/registry.go b/pkg/inventory/registry.go index b8a70a3420..101f8ee944 100644 --- a/pkg/inventory/registry.go +++ b/pkg/inventory/registry.go @@ -169,8 +169,9 @@ func (r *Inventory) ToolsetDescriptions() map[ToolsetID]string { } // ToolsForRegistration returns AvailableTools(ctx) post-processed exactly as -// RegisterTools would expose them: with MCP Apps UI metadata stripped when -// the client cannot consume it. Useful for documentation generators and +// RegisterTools would expose them: with MCP Apps UI metadata stripped and +// UI-capability-gated input-schema properties (e.g. show_ui) removed when +// the client cannot consume them. Useful for documentation generators and // diagnostics that need the same view of the tool surface the server would // register. // @@ -186,6 +187,7 @@ func (r *Inventory) ToolsForRegistration(ctx context.Context) []ServerTool { tools := r.AvailableTools(ctx) if shouldStripMCPAppsMetadata(ctx, r.checkFeatureFlag(ctx, mcpAppsFeatureFlag)) { tools = stripMCPAppsMetadata(tools) + tools = stripUIOnlySchemaProperties(tools) } return tools } @@ -206,9 +208,10 @@ func shouldStripMCPAppsMetadata(ctx context.Context, featureFlagEnabled bool) bo // RegisterTools registers all available tools with the server using the provided dependencies. // The context is used for feature flag evaluation and client capability checks. // -// MCP Apps UI metadata (`_meta.ui`) is stripped from the registered tools -// when either the MCP Apps feature flag is not enabled for this request, or -// the client did not advertise the io.modelcontextprotocol/ui extension. The +// MCP Apps UI metadata (`_meta.ui`) and UI-capability-gated input-schema +// properties (e.g. `show_ui`) are stripped from the registered tools when +// either the MCP Apps feature flag is not enabled for this request, or the +// client did not advertise the io.modelcontextprotocol/ui extension. The // strip happens here (rather than at Build() time) so the per-request // context is in scope — HTTP feature checkers that read insiders mode or // user identity from ctx would otherwise see context.Background() and diff --git a/pkg/inventory/registry_test.go b/pkg/inventory/registry_test.go index 20b1fb718c..bcdd70f000 100644 --- a/pkg/inventory/registry_test.go +++ b/pkg/inventory/registry_test.go @@ -4,9 +4,12 @@ import ( "context" "encoding/json" "fmt" + "maps" + "slices" "testing" ghcontext "github.com/github/github-mcp-server/pkg/context" + "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/require" ) @@ -2019,6 +2022,267 @@ func TestStripMCPAppsMetadata(t *testing.T) { require.Nil(t, result[2].Tool.Meta) } +// mockToolWithSchema creates a ServerTool with the given *jsonschema.Schema as +// InputSchema. Used to exercise schema-based strip helpers. +func mockToolWithSchema(name string, toolsetID string, schema *jsonschema.Schema) ServerTool { + return NewServerTool( + mcp.Tool{ + Name: name, + Annotations: &mcp.ToolAnnotations{ + ReadOnlyHint: true, + }, + InputSchema: schema, + }, + testToolsetMetadata(toolsetID), + func(_ context.Context, _ *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return nil, nil + }, + ) +} + +func TestStripSchemaProperties(t *testing.T) { + tests := []struct { + name string + schema any + keys []string + expectChange bool + wantProperties []string // property names expected to remain (order-independent) + wantRequired []string // required fields expected to remain (order-independent) + }{ + { + name: "nil schema - no change", + schema: nil, + keys: []string{"show_ui"}, + expectChange: false, + }, + { + name: "RawMessage schema - skipped (not a *jsonschema.Schema)", + schema: json.RawMessage(`{"type":"object","properties":{"show_ui":{"type":"boolean"}}}`), + keys: []string{"show_ui"}, + expectChange: false, + }, + { + name: "schema without the key - no change", + schema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": {Type: "string"}, + }, + }, + keys: []string{"show_ui"}, + expectChange: false, + }, + { + name: "empty keys list - no change", + schema: &jsonschema.Schema{Type: "object", Properties: map[string]*jsonschema.Schema{"show_ui": {Type: "boolean"}}}, + keys: []string{}, + expectChange: false, + }, + { + name: "schema with the key - stripped, others preserved", + schema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": {Type: "string"}, + "repo": {Type: "string"}, + "show_ui": {Type: "boolean"}, + }, + Required: []string{"owner", "repo"}, + }, + keys: []string{"show_ui"}, + expectChange: true, + wantProperties: []string{"owner", "repo"}, + wantRequired: []string{"owner", "repo"}, + }, + { + name: "key in required list is also stripped", + schema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": {Type: "string"}, + "show_ui": {Type: "boolean"}, + }, + Required: []string{"owner", "show_ui"}, + }, + keys: []string{"show_ui"}, + expectChange: true, + wantProperties: []string{"owner"}, + wantRequired: []string{"owner"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tool := NewServerTool( + mcp.Tool{ + Name: "test", + Annotations: &mcp.ToolAnnotations{ReadOnlyHint: true}, + InputSchema: tt.schema, + }, + testToolsetMetadata("toolset1"), + func(_ context.Context, _ *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return nil, nil + }, + ) + + result := stripSchemaProperties(tool, tt.keys) + + if !tt.expectChange { + require.Nil(t, result, "expected no change but got result") + return + } + + require.NotNil(t, result, "expected change but got nil") + schema, ok := result.Tool.InputSchema.(*jsonschema.Schema) + require.True(t, ok, "result schema should remain *jsonschema.Schema") + require.ElementsMatch(t, tt.wantProperties, slices.Collect(maps.Keys(schema.Properties))) + require.ElementsMatch(t, tt.wantRequired, schema.Required) + + // Original schema must not be mutated. + origSchema := tt.schema.(*jsonschema.Schema) + _, stillThere := origSchema.Properties["show_ui"] + require.True(t, stillThere || !slices.Contains(tt.keys, "show_ui"), "original schema should not be mutated") + }) + } +} + +func TestStripUIOnlySchemaProperties(t *testing.T) { + tools := []ServerTool{ + mockToolWithSchema("with_show_ui", "toolset1", &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": {Type: "string"}, + "show_ui": {Type: "boolean"}, + }, + }), + mockToolWithSchema("without_show_ui", "toolset1", &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": {Type: "string"}, + }, + }), + mockTool("raw_schema_tool", "toolset1", true), // InputSchema is json.RawMessage + } + + result := stripUIOnlySchemaProperties(tools) + require.Len(t, result, 3) + + stripped := result[0].Tool.InputSchema.(*jsonschema.Schema) + require.NotContains(t, stripped.Properties, "show_ui", + "show_ui should be stripped from a tool that declares it") + require.Contains(t, stripped.Properties, "owner", + "other properties on the same schema must be preserved") + + // Tool without show_ui: same value returned (no allocation), schema untouched. + require.Same(t, tools[1].Tool.InputSchema, result[1].Tool.InputSchema, + "tools without the gated property must be returned unchanged") + + // Tool with an opaque (json.RawMessage) schema: passed through untouched. + require.Equal(t, tools[2].Tool.InputSchema, result[2].Tool.InputSchema, + "tools with a non-*jsonschema.Schema input schema must be passed through") +} + +// TestConditionalSchemaPropertyDescriptions ensures every property that +// inventory strips per-request also has a human-readable condition the doc +// generator can render. A future addition to uiOnlySchemaProperties that +// forgets to wire a description through will fail here. +func TestConditionalSchemaPropertyDescriptions(t *testing.T) { + t.Parallel() + + descs := ConditionalSchemaPropertyDescriptions() + require.NotEmpty(t, descs, "expected at least show_ui to be advertised as conditional") + + for _, name := range uiOnlySchemaProperties { + desc, ok := descs[name] + require.Truef(t, ok, "ui-only property %q must have a conditional description", name) + require.NotEmptyf(t, desc, "conditional description for %q must be non-empty", name) + } +} + +func TestToolsForRegistration_StripsShowUIUnderSameGate(t *testing.T) { + // A tool whose schema declares both `_meta.ui` and `show_ui`. The strip + // for both must fire — or not — together, governed by the same gate + // already covered by TestShouldStripMCPAppsMetadata. + makeTool := func() ServerTool { + st := mockToolWithSchema("ui_tool", "toolset1", &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": {Type: "string"}, + "show_ui": {Type: "boolean"}, + }, + }) + st.Tool.Meta = map[string]any{ + "ui": map[string]any{"resourceUri": "ui://example"}, + "description": "kept", + } + return st + } + + mcpAppsChecker := func(_ context.Context, flag string) (bool, error) { + return flag == mcpAppsFeatureFlag, nil + } + + tests := []struct { + name string + ctx context.Context + ffOn bool + wantShowUI bool // expect show_ui to remain in registered schema + wantUIMeta bool // expect _meta.ui to remain on registered tool + }{ + { + name: "FF off, capability unknown -> both stripped", + ctx: context.Background(), + ffOn: false, + wantShowUI: false, + wantUIMeta: false, + }, + { + name: "FF on, capability unknown -> both kept", + ctx: context.Background(), + ffOn: true, + wantShowUI: true, + wantUIMeta: true, + }, + { + name: "FF on, capability present -> both kept", + ctx: ghcontext.WithUISupport(context.Background(), true), + ffOn: true, + wantShowUI: true, + wantUIMeta: true, + }, + { + name: "FF on, capability explicitly absent -> both stripped", + ctx: ghcontext.WithUISupport(context.Background(), false), + ffOn: true, + wantShowUI: false, + wantUIMeta: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + builder := NewBuilder().SetTools([]ServerTool{makeTool()}).WithToolsets([]string{"all"}) + if tc.ffOn { + builder = builder.WithFeatureChecker(mcpAppsChecker) + } + reg := mustBuild(t, builder) + + registered := reg.ToolsForRegistration(tc.ctx) + require.Len(t, registered, 1) + schema, ok := registered[0].Tool.InputSchema.(*jsonschema.Schema) + require.True(t, ok) + + _, hasShowUI := schema.Properties["show_ui"] + require.Equal(t, tc.wantShowUI, hasShowUI, + "show_ui presence in registered schema should match strip gate") + + _, hasUIMeta := registered[0].Tool.Meta["ui"] + require.Equal(t, tc.wantUIMeta, hasUIMeta, + "_meta.ui presence on registered tool should match strip gate") + }) + } +} + func TestStripMetaKeys_MultipleKeys(t *testing.T) { // This test verifies the mechanism works for multiple keys keys := []string{"ui", "experimental_feature", "beta"} @@ -2203,23 +2467,17 @@ func TestCreateExcludeToolsFilter(t *testing.T) { // captureRegisteredTools mirrors RegisterTools' per-request strip behavior so // tests can verify what the wire sees, without requiring tools to have real -// handlers (RegisterTools panics on tools without HandlerFunc). +// handlers (RegisterTools panics on tools without HandlerFunc). It delegates +// to ToolsForRegistration so any future strip added there is picked up +// automatically. func captureRegisteredTools(ctx context.Context, t *testing.T, reg *Inventory) []*mcp.Tool { t.Helper() - tools := reg.AvailableTools(ctx) - out := make([]*mcp.Tool, 0, len(tools)) - for i := range tools { - toolCopy := tools[i].Tool + forReg := reg.ToolsForRegistration(ctx) + out := make([]*mcp.Tool, 0, len(forReg)) + for i := range forReg { + toolCopy := forReg[i].Tool out = append(out, &toolCopy) } - if shouldStripMCPAppsMetadata(ctx, reg.checkFeatureFlag(ctx, mcpAppsFeatureFlag)) { - for _, tt := range out { - delete(tt.Meta, "ui") - if len(tt.Meta) == 0 { - tt.Meta = nil - } - } - } return out } diff --git a/pkg/octicons/icons/code-square-dark.png b/pkg/octicons/icons/code-square-dark.png new file mode 100644 index 0000000000..8e2d8d0c98 Binary files /dev/null and b/pkg/octicons/icons/code-square-dark.png differ diff --git a/pkg/octicons/icons/code-square-light.png b/pkg/octicons/icons/code-square-light.png new file mode 100644 index 0000000000..bccf0006a0 Binary files /dev/null and b/pkg/octicons/icons/code-square-light.png differ diff --git a/pkg/octicons/required_icons.txt b/pkg/octicons/required_icons.txt index 7911b46eb8..15dc444956 100644 --- a/pkg/octicons/required_icons.txt +++ b/pkg/octicons/required_icons.txt @@ -19,6 +19,7 @@ bell book check-circle codescan +code-square comment-discussion copilot dependabot diff --git a/pkg/utils/result.go b/pkg/utils/result.go index 1bfd800e28..99c37602bc 100644 --- a/pkg/utils/result.go +++ b/pkg/utils/result.go @@ -59,3 +59,27 @@ func NewToolResultResourceLink(message string, link *mcp.ResourceLink) *mcp.Call IsError: false, } } + +// NewToolResultAwaitingFormSubmission signals to the agent that a tool call +// has been intercepted to show an MCP App form to the user and has NOT +// performed the requested operation. The agent must stop, not chain dependent +// tool calls, and not claim the operation succeeded. The result is marked +// IsError=true so agents that bail on error don't proceed; the host still +// renders the UI because rendering is keyed off the tool's _meta.ui, not the +// result. The MCP App form will submit the operation directly when the user +// clicks submit, after which a ui/update-model-context call delivers the real +// outcome to the agent. +func NewToolResultAwaitingFormSubmission(message string) *mcp.CallToolResult { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{ + Text: message, + }, + }, + StructuredContent: map[string]any{ + "status": "awaiting_user_submission", + "reason": "An interactive form is being shown to the user. The operation has not been performed.", + }, + IsError: true, + } +} diff --git a/ui/package-lock.json b/ui/package-lock.json index 4046bc28f9..f8ebc8aedb 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -23,7 +23,7 @@ "@types/react-dom": "^18.0.0", "@vitejs/plugin-react": "^6.0.2", "typescript": "^5.7.0", - "vite": "^8.0.13", + "vite": "^8.0.16", "vite-plugin-singlefile": "^2.3.3" }, "engines": { @@ -31,13 +31,13 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "license": "MIT", "peer": true, "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -46,9 +46,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", - "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", "license": "MIT", "peer": true, "engines": { @@ -56,21 +56,21 @@ } }, "node_modules/@babel/core": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", - "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "license": "MIT", "peer": true, "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -87,14 +87,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.0.tgz", - "integrity": "sha512-vSH118/wwM/pLR38g/Sgk05sNtro6TlTJKuiMXDaZqPUfjTFcudpCOt00IhOfj+1BFAX+UFAlzCU+6WXr3GLFQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", "license": "MIT", "peer": true, "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -117,14 +117,14 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", - "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", "license": "MIT", "peer": true, "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -134,9 +134,9 @@ } }, "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", "license": "MIT", "peer": true, "engines": { @@ -144,29 +144,29 @@ } }, "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", "license": "MIT", "peer": true, "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "license": "MIT", "peer": true, "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -186,9 +186,9 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "license": "MIT", "peer": true, "engines": { @@ -196,9 +196,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "license": "MIT", "peer": true, "engines": { @@ -206,9 +206,9 @@ } }, "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", "license": "MIT", "peer": true, "engines": { @@ -216,27 +216,27 @@ } }, "node_modules/@babel/helpers": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", - "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", "license": "MIT", "peer": true, "dependencies": { - "@babel/template": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", - "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", "license": "MIT", "peer": true, "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.7" }, "bin": { "parser": "bin/babel-parser.js" @@ -271,33 +271,33 @@ } }, "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "license": "MIT", "peer": true, "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", - "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", "license": "MIT", "peer": true, "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", "debug": "^4.3.1" }, "engines": { @@ -305,14 +305,14 @@ } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", "license": "MIT", "peer": true, "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -559,14 +559,14 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", - "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", + "integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@tybys/wasm-util": "^0.10.1" + "@tybys/wasm-util": "^0.10.2" }, "funding": { "type": "github", @@ -584,9 +584,9 @@ "license": "BSD-3-Clause" }, "node_modules/@oxc-project/types": { - "version": "0.130.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.130.0.tgz", - "integrity": "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==", + "version": "0.133.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", + "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", "dev": true, "license": "MIT", "funding": { @@ -1431,9 +1431,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.1.tgz", - "integrity": "sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", + "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", "cpu": [ "arm64" ], @@ -1448,9 +1448,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.1.tgz", - "integrity": "sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", + "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", "cpu": [ "arm64" ], @@ -1465,9 +1465,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.1.tgz", - "integrity": "sha512-YKrVwQjIRBPo+5G/u03wGjbdy4q7pyzCe93DK9VJ7zkVmeg8LJ7GbgsiHWdR4xSoe4CAXRD7Bcjgbtr64bkXNg==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", + "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", "cpu": [ "x64" ], @@ -1482,9 +1482,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.1.tgz", - "integrity": "sha512-z/oBsREo46SsFqBwYtFe0kpJeBijAT48O/WXLI4suiCLBkr03RTtTJMCzSdDd2znlh8VJizL09XVkQgk8IZonw==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", + "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", "cpu": [ "x64" ], @@ -1499,9 +1499,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.1.tgz", - "integrity": "sha512-ik8q7GM11zxvYxFc2PeDcT6TBvhCQMaUxfph/M5l9sKuTs/Sjg3L+Byw0F7w0ZVLBZmx30P+gG0ECzzN+MFcmQ==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", + "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", "cpu": [ "arm" ], @@ -1516,9 +1516,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.1.tgz", - "integrity": "sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", + "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", "cpu": [ "arm64" ], @@ -1533,9 +1533,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.1.tgz", - "integrity": "sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", + "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", "cpu": [ "arm64" ], @@ -1550,9 +1550,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.1.tgz", - "integrity": "sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", + "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", "cpu": [ "ppc64" ], @@ -1567,9 +1567,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.1.tgz", - "integrity": "sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", + "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", "cpu": [ "s390x" ], @@ -1584,9 +1584,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.1.tgz", - "integrity": "sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", + "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", "cpu": [ "x64" ], @@ -1601,9 +1601,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.1.tgz", - "integrity": "sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", + "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", "cpu": [ "x64" ], @@ -1618,9 +1618,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.1.tgz", - "integrity": "sha512-/Mh0Zhq3OP7fVs0kcQHZP6lZEthMGTaSf8UBQYSFEZDWGXXlEC+nJ6EqenaK2t4LBXMe3A+K/G2BVXXdtOr4PQ==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", + "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", "cpu": [ "arm64" ], @@ -1635,9 +1635,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.1.tgz", - "integrity": "sha512-+1xc9X45l8ufsBAm6Gjvx2qDRIY9lTVt0cgWNcJ+1gdhXvkbxePA60yRTwSTuXL09CMhyJmjpV7E3NoyxbqFQQ==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", + "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", "cpu": [ "wasm32" ], @@ -1654,9 +1654,9 @@ } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.1.tgz", - "integrity": "sha512-1D+UqZdfnuR+Jy1GgMJwi85bD40H21uNmOPRWQhw4oRSuolZ/B5rixZ45DK2KXOTCvmVCecauWgEhbw8bI7tOw==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", + "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", "cpu": [ "arm64" ], @@ -1671,9 +1671,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.1.tgz", - "integrity": "sha512-INAycaWuhlOK3wk4mRHGsdgwYWmd9cChdPdE9bwWmy6rn9VqVNYNFGhOdXrofXUxwHIncSiPNb8tNm8knDVIeQ==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", + "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", "cpu": [ "x64" ], @@ -2076,13 +2076,16 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.9.19", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", - "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==", + "version": "2.10.37", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.37.tgz", + "integrity": "sha512-girxaJ7WZssDOFhzCGZTDKoTa1gk6A1TbflaYTpykLJ4UU9Fz9kx1aREM8JCuoVHbL8X8T/mJg7w2oYSq72Oig==", "license": "Apache-2.0", "peer": true, "bin": { - "baseline-browser-mapping": "dist/cli.js" + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" } }, "node_modules/body-parser": { @@ -2124,9 +2127,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", "funding": [ { "type": "opencollective", @@ -2144,11 +2147,11 @@ "license": "MIT", "peer": true, "dependencies": { - "baseline-browser-mapping": "^2.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" @@ -2209,9 +2212,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001767", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001767.tgz", - "integrity": "sha512-34+zUAMhSH+r+9eKmYG+k2Rpt8XttfE4yXAjoZvkAPs15xcYQhyBYdalJ65BzivAvGRMViEjy6oKr/S91loekQ==", + "version": "1.0.30001799", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", + "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==", "funding": [ { "type": "opencollective", @@ -2529,9 +2532,9 @@ "peer": true }, "node_modules/electron-to-chromium": { - "version": "1.5.286", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", - "integrity": "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==", + "version": "1.5.372", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.372.tgz", + "integrity": "sha512-M3yhbAlilnwqC8D21t28UCDGHyitShTmmLRU/H+b74P6Ski16Nb9HONYEaVpMj/pwC7BEo5B95FpjODLCWbtfA==", "license": "ISC", "peer": true }, @@ -4575,9 +4578,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", "dev": true, "funding": [ { @@ -4604,11 +4607,14 @@ } }, "node_modules/node-releases": { - "version": "2.0.27", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", - "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "version": "2.0.47", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", + "integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==", "license": "MIT", - "peer": true + "peer": true, + "engines": { + "node": ">=18" + } }, "node_modules/object-assign": { "version": "4.1.1", @@ -4740,9 +4746,9 @@ } }, "node_modules/postcss": { - "version": "8.5.14", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", - "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", "dev": true, "funding": [ { @@ -4760,7 +4766,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -5009,13 +5015,13 @@ } }, "node_modules/rolldown": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz", - "integrity": "sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", + "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.130.0", + "@oxc-project/types": "=0.133.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -5025,21 +5031,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.1", - "@rolldown/binding-darwin-arm64": "1.0.1", - "@rolldown/binding-darwin-x64": "1.0.1", - "@rolldown/binding-freebsd-x64": "1.0.1", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.1", - "@rolldown/binding-linux-arm64-gnu": "1.0.1", - "@rolldown/binding-linux-arm64-musl": "1.0.1", - "@rolldown/binding-linux-ppc64-gnu": "1.0.1", - "@rolldown/binding-linux-s390x-gnu": "1.0.1", - "@rolldown/binding-linux-x64-gnu": "1.0.1", - "@rolldown/binding-linux-x64-musl": "1.0.1", - "@rolldown/binding-openharmony-arm64": "1.0.1", - "@rolldown/binding-wasm32-wasi": "1.0.1", - "@rolldown/binding-win32-arm64-msvc": "1.0.1", - "@rolldown/binding-win32-x64-msvc": "1.0.1" + "@rolldown/binding-android-arm64": "1.0.3", + "@rolldown/binding-darwin-arm64": "1.0.3", + "@rolldown/binding-darwin-x64": "1.0.3", + "@rolldown/binding-freebsd-x64": "1.0.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", + "@rolldown/binding-linux-arm64-gnu": "1.0.3", + "@rolldown/binding-linux-arm64-musl": "1.0.3", + "@rolldown/binding-linux-ppc64-gnu": "1.0.3", + "@rolldown/binding-linux-s390x-gnu": "1.0.3", + "@rolldown/binding-linux-x64-gnu": "1.0.3", + "@rolldown/binding-linux-x64-musl": "1.0.3", + "@rolldown/binding-openharmony-arm64": "1.0.3", + "@rolldown/binding-wasm32-wasi": "1.0.3", + "@rolldown/binding-win32-arm64-msvc": "1.0.3", + "@rolldown/binding-win32-x64-msvc": "1.0.3" } }, "node_modules/router": { @@ -5385,9 +5391,9 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.16", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", - "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { @@ -5732,17 +5738,17 @@ } }, "node_modules/vite": { - "version": "8.0.13", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.13.tgz", - "integrity": "sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==", + "version": "8.0.16", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", + "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", - "postcss": "^8.5.14", - "rolldown": "1.0.1", - "tinyglobby": "^0.2.16" + "postcss": "^8.5.15", + "rolldown": "1.0.3", + "tinyglobby": "^0.2.17" }, "bin": { "vite": "bin/vite.js" diff --git a/ui/package.json b/ui/package.json index b5bf095851..9644b72d9e 100644 --- a/ui/package.json +++ b/ui/package.json @@ -29,7 +29,7 @@ "@types/react-dom": "^18.0.0", "@vitejs/plugin-react": "^6.0.2", "typescript": "^5.7.0", - "vite": "^8.0.13", + "vite": "^8.0.16", "vite-plugin-singlefile": "^2.3.3" } } diff --git a/ui/scripts/build.mjs b/ui/scripts/build.mjs index c99d846039..9efa58524c 100644 --- a/ui/scripts/build.mjs +++ b/ui/scripts/build.mjs @@ -1,12 +1,12 @@ // Build all UI apps in a single Node process. // -// Replaces three serial `cross-env APP= vite build` invocations: doing it -// in one process avoids paying Vite/plugin startup cost three times and is +// Replaces serial `cross-env APP= vite build` invocations: doing it +// in one process avoids paying Vite/plugin startup cost for each app and is // portable without `cross-env`. import { build } from "vite"; -const apps = ["get-me", "issue-write", "pr-write"]; +const apps = ["get-me", "issue-write", "pr-write", "pr-edit"]; for (const app of apps) { process.env.APP = app; diff --git a/ui/src/apps/issue-write/App.tsx b/ui/src/apps/issue-write/App.tsx index 6c46b8c081..6372e2d503 100644 --- a/ui/src/apps/issue-write/App.tsx +++ b/ui/src/apps/issue-write/App.tsx @@ -1,4 +1,4 @@ -import { StrictMode, useState, useCallback, useEffect } from "react"; +import { StrictMode, useState, useCallback, useEffect, useMemo, useRef } from "react"; import { createRoot } from "react-dom/client"; import { Box, @@ -8,10 +8,19 @@ import { Flash, Spinner, FormControl, + CounterLabel, + ActionMenu, + ActionList, + Label, } from "@primer/react"; import { IssueOpenedIcon, CheckCircleIcon, + TagIcon, + PersonIcon, + RepoIcon, + MilestoneIcon, + LockIcon, } from "@primer/octicons-react"; import { AppProvider } from "../../components/AppProvider"; import { useMcpApp } from "../../hooks/useMcpApp"; @@ -27,11 +36,251 @@ interface IssueResult { URL?: string; } +interface LabelItem { + id: string; + text: string; + color: string; +} + +interface AssigneeItem { + id: string; + text: string; +} + +interface MilestoneItem { + id: string; + number: number; + text: string; + description: string; +} + +interface IssueTypeItem { + id: string; + text: string; +} + +type IssueState = "open" | "closed"; +type StateReason = "completed" | "not_planned" | "duplicate"; +type IssueFieldPrimitive = string | number | boolean; + +interface IssueFieldOption { + id: string; + name: string; + description: string; + color: string; +} + +interface IssueFieldItem { + id: string; + name: string; + data_type: string; + description: string; + options: IssueFieldOption[]; +} + +interface IssueFieldValue { + value?: IssueFieldPrimitive; + optionName?: string; + cleared?: boolean; +} + +interface IssueFieldSubmission { + field_name: string; + value?: IssueFieldPrimitive; + field_option_name?: string; + delete?: boolean; +} + +interface RepositoryItem { + id: string; + owner: string; + name: string; + fullName: string; + isPrivate: boolean; +} + +// Calculate text color based on background luminance +function getContrastColor(hexColor: string): string { + const r = parseInt(hexColor.substring(0, 2), 16); + const g = parseInt(hexColor.substring(2, 4), 16); + const b = parseInt(hexColor.substring(4, 6), 16); + const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255; + return luminance > 0.5 ? "#000000" : "#ffffff"; +} + +const stateReasonOptions: Array<{ value: StateReason; label: string; description: string }> = [ + { value: "completed", label: "Completed", description: "The work is done" }, + { value: "not_planned", label: "Not planned", description: "The issue won't be worked on" }, + { value: "duplicate", label: "Duplicate", description: "Another issue tracks this" }, +]; + +function normalizeSwatchColor(color: string): string { + const trimmed = color.trim(); + if (!trimmed) return "var(--borderColor-default, var(--color-border-default))"; + if (/^#?[0-9a-fA-F]{6}$/.test(trimmed)) { + return trimmed.startsWith("#") ? trimmed : `#${trimmed}`; + } + return trimmed.toLowerCase(); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function stringValue(value: unknown): string | undefined { + if (typeof value === "string" && value.trim()) return value; + if (typeof value === "number" && Number.isFinite(value)) return String(value); + return undefined; +} + +function parseIssueState(value: unknown): IssueState | null { + return value === "open" || value === "closed" ? value : null; +} + +function parseStateReason(value: unknown): StateReason | null { + return value === "completed" || value === "not_planned" || value === "duplicate" ? value : null; +} + +function normalizeRawIssueFieldValue( + field: IssueFieldItem | undefined, + rawValue: unknown +): IssueFieldValue | null { + if (rawValue === null || rawValue === undefined) return null; + + if (isRecord(rawValue)) { + const optionName = + stringValue(rawValue.optionName) || + stringValue(rawValue.field_option_name) || + stringValue(rawValue.name); + if (field?.data_type === "single_select" && optionName) { + return { optionName }; + } + return normalizeRawIssueFieldValue( + field, + rawValue.value ?? rawValue.text ?? rawValue.number ?? rawValue.date ?? rawValue.name + ); + } + + if (field?.data_type === "single_select") { + const optionName = stringValue(rawValue); + return optionName ? { optionName } : null; + } + + if ( + typeof rawValue === "string" || + typeof rawValue === "number" || + typeof rawValue === "boolean" + ) { + return { value: rawValue }; + } + + return null; +} + +function parseStringIssueFieldValue( + entry: string, + fieldsByName: Map +): [string, IssueFieldValue] | null { + const match = entry.match(/^([^:=]+)\s*[:=]\s*(.*)$/); + if (!match) return null; + + const fieldName = match[1].trim(); + const field = fieldsByName.get(fieldName); + if (!field) return null; + + const normalized = normalizeRawIssueFieldValue(field, match[2].trim()); + return normalized ? [fieldName, normalized] : null; +} + +function normalizeIssueFieldEntry( + entry: unknown, + fieldsByName: Map +): [string, IssueFieldValue] | null { + if (typeof entry === "string") return parseStringIssueFieldValue(entry, fieldsByName); + if (!isRecord(entry)) return null; + + const fieldRecord = isRecord(entry.field) ? entry.field : undefined; + const entryName = stringValue(entry.name); + const fieldName = + stringValue(entry.field_name) || + stringValue(entry.fieldName) || + (fieldRecord ? stringValue(fieldRecord.name) : undefined) || + entryName; + if (!fieldName) return null; + + const field = fieldsByName.get(fieldName); + if (!field) return null; + + if (entry.delete === true || entry.cleared === true) { + return [fieldName, { cleared: true }]; + } + + const directOptionName = + stringValue(entry.field_option_name) || + stringValue(entry.fieldOptionName) || + stringValue(entry.optionName) || + (field.data_type === "single_select" && entryName && entryName !== fieldName ? entryName : undefined); + if (directOptionName) return [fieldName, { optionName: directOptionName }]; + + const optionRecord = isRecord(entry.option) ? entry.option : undefined; + const optionName = optionRecord ? stringValue(optionRecord.name) : undefined; + if (optionName) return [fieldName, { optionName }]; + + const normalized = normalizeRawIssueFieldValue( + field, + entry.value ?? entry.text ?? entry.number ?? entry.date + ); + return normalized ? [fieldName, normalized] : null; +} + +function normalizeIssueFieldValues( + input: unknown, + fields: IssueFieldItem[] +): Record { + const fieldsByName = new Map(fields.map((field) => [field.name, field])); + const values: Record = {}; + + if (Array.isArray(input)) { + for (const item of input) { + const normalized = normalizeIssueFieldEntry(item, fieldsByName); + if (normalized) values[normalized[0]] = normalized[1]; + } + return values; + } + + if (!isRecord(input)) return values; + + const normalizedEntry = normalizeIssueFieldEntry(input, fieldsByName); + if (normalizedEntry) { + values[normalizedEntry[0]] = normalizedEntry[1]; + return values; + } + + for (const [fieldName, rawValue] of Object.entries(input)) { + const field = fieldsByName.get(fieldName); + if (!field) continue; + + if (isRecord(rawValue)) { + const nested = normalizeIssueFieldEntry({ ...rawValue, field_name: fieldName }, fieldsByName); + if (nested) { + values[fieldName] = nested[1]; + continue; + } + } + + const normalized = normalizeRawIssueFieldValue(field, rawValue); + if (normalized) values[fieldName] = normalized; + } + + return values; +} + function SuccessView({ issue, owner, repo, submittedTitle, + submittedLabels, isUpdate, openLink, }: { @@ -39,6 +288,7 @@ function SuccessView({ owner: string; repo: string; submittedTitle: string; + submittedLabels: LabelItem[]; isUpdate: boolean; openLink: (url: string) => Promise; }) { @@ -118,6 +368,22 @@ function SuccessView({ {owner}/{repo} + {submittedLabels.length > 0 && ( + + {submittedLabels.map((label) => ( + + ))} + + )} @@ -131,23 +397,576 @@ function CreateIssueApp() { const [error, setError] = useState(null); const [successIssue, setSuccessIssue] = useState(null); + // Labels state + const [availableLabels, setAvailableLabels] = useState([]); + const [selectedLabels, setSelectedLabels] = useState([]); + const [labelsLoading, setLabelsLoading] = useState(false); + const [labelsFilter, setLabelsFilter] = useState(""); + + // Assignees state + const [availableAssignees, setAvailableAssignees] = useState([]); + const [selectedAssignees, setSelectedAssignees] = useState([]); + const [assigneesLoading, setAssigneesLoading] = useState(false); + const [assigneesFilter, setAssigneesFilter] = useState(""); + + // Milestones state + const [availableMilestones, setAvailableMilestones] = useState([]); + const [selectedMilestone, setSelectedMilestone] = useState(null); + const [milestonesLoading, setMilestonesLoading] = useState(false); + + // Issue types state + const [availableIssueTypes, setAvailableIssueTypes] = useState([]); + const [selectedIssueType, setSelectedIssueType] = useState(null); + const [issueTypesLoading, setIssueTypesLoading] = useState(false); + + // State transition state + const [currentState, setCurrentState] = useState("open"); + const [stateReason, setStateReason] = useState("completed"); + const [duplicateOf, setDuplicateOf] = useState(""); + const [prefilledStateChange, setPrefilledStateChange] = useState(null); + + // Issue fields state + const [availableIssueFields, setAvailableIssueFields] = useState([]); + const [fieldValues, setFieldValues] = useState>({}); + + // Repository state + const [selectedRepo, setSelectedRepo] = useState(null); + const [repoSearchResults, setRepoSearchResults] = useState([]); + const [repoSearchLoading, setRepoSearchLoading] = useState(false); + const [repoFilter, setRepoFilter] = useState(""); + const { app, error: appError, toolInput, callTool, hostContext, setModelContext, openLink } = useMcpApp({ appName: "github-mcp-server-issue-write", }); + // Get method and issue_number from toolInput const method = (toolInput?.method as string) || "create"; const issueNumber = toolInput?.issue_number as number | undefined; const isUpdateMode = method === "update" && issueNumber !== undefined; - const owner = (toolInput?.owner as string) || ""; - const repo = (toolInput?.repo as string) || ""; - // Pre-fill from toolInput + // Initialize from toolInput or selected repo + const owner = selectedRepo?.owner || (toolInput?.owner as string) || ""; + const repo = selectedRepo?.name || (toolInput?.repo as string) || ""; + + // Search repositories when filter changes + useEffect(() => { + if (!app || !repoFilter.trim()) { + setRepoSearchResults([]); + return; + } + + const searchRepos = async () => { + setRepoSearchLoading(true); + try { + const result = await callTool("search_repositories", { + query: repoFilter, + perPage: 10, + }); + if (result && !result.isError && result.content) { + const textContent = result.content.find( + (c) => c.type === "text" + ); + if (textContent && textContent.type === "text" && textContent.text) { + const data = JSON.parse(textContent.text); + const repos = (data.repositories || data.items || []).map( + (r: { id?: number; owner?: { login?: string } | string; name?: string; full_name?: string; private?: boolean }) => ({ + id: String(r.id || r.full_name), + owner: + typeof r.owner === "string" + ? r.owner + : r.owner?.login || r.full_name?.split("/")[0] || "", + name: r.name || r.full_name?.split("/")[1] || "", + fullName: r.full_name || "", + isPrivate: r.private || false, + }) + ); + setRepoSearchResults(repos); + } + } + } catch (e) { + console.error("Failed to search repositories:", e); + } finally { + setRepoSearchLoading(false); + } + }; + + const debounce = setTimeout(searchRepos, 300); + return () => clearTimeout(debounce); + }, [app, callTool, repoFilter]); + + // Load labels, assignees, milestones, issue types, and issue fields when owner/repo available useEffect(() => { - if (toolInput?.title) setTitle(toolInput.title as string); - if (toolInput?.body) setBody(toolInput.body as string); + if (!owner || !repo || !app) return; + + const loadLabels = async () => { + setLabelsLoading(true); + try { + const result = await callTool("ui_get", { method: "labels", owner, repo }); + if (result && !result.isError && result.content) { + const textContent = result.content.find( + (c: { type: string }) => c.type === "text" + ); + if (textContent && "text" in textContent) { + const data = JSON.parse(textContent.text as string); + const labels = (data.labels || []).map( + (l: { name: string; color: string; id: string }) => ({ + id: l.id || l.name, + text: l.name, + color: l.color, + }) + ); + setAvailableLabels(labels); + } + } + } catch (e) { + console.error("Failed to load labels:", e); + } finally { + setLabelsLoading(false); + } + }; + + const loadAssignees = async () => { + setAssigneesLoading(true); + try { + const result = await callTool("ui_get", { method: "assignees", owner, repo }); + if (result && !result.isError && result.content) { + const textContent = result.content.find( + (c: { type: string }) => c.type === "text" + ); + if (textContent && "text" in textContent) { + const data = JSON.parse(textContent.text as string); + const assignees = (data.assignees || []).map( + (a: { login: string }) => ({ + id: a.login, + text: a.login, + }) + ); + setAvailableAssignees(assignees); + } + } + } catch (e) { + console.error("Failed to load assignees:", e); + } finally { + setAssigneesLoading(false); + } + }; + + const loadMilestones = async () => { + setMilestonesLoading(true); + try { + const result = await callTool("ui_get", { method: "milestones", owner, repo }); + if (result && !result.isError && result.content) { + const textContent = result.content.find( + (c: { type: string }) => c.type === "text" + ); + if (textContent && "text" in textContent) { + const data = JSON.parse(textContent.text as string); + const milestones = (data.milestones || []).map( + (m: { number: number; title: string; description: string }) => ({ + id: String(m.number), + number: m.number, + text: m.title, + description: m.description || "", + }) + ); + setAvailableMilestones(milestones); + } + } + } catch (e) { + console.error("Failed to load milestones:", e); + } finally { + setMilestonesLoading(false); + } + }; + + const loadIssueTypes = async () => { + setIssueTypesLoading(true); + try { + const result = await callTool("ui_get", { method: "issue_types", owner }); + if (result && !result.isError && result.content) { + const textContent = result.content.find( + (c: { type: string }) => c.type === "text" + ); + if (textContent && "text" in textContent) { + const data = JSON.parse(textContent.text as string); + // ui_get returns array directly or wrapped in issue_types/types + const typesArray = Array.isArray(data) ? data : (data.issue_types || data.types || []); + const types = typesArray.map( + (t: { id: number; name: string; description?: string } | string) => { + if (typeof t === "string") { + return { id: t, text: t }; + } + return { id: String(t.id || t.name), text: t.name }; + } + ); + setAvailableIssueTypes(types); + } + } + } catch (e) { + // Issue types may not be available for all repos/orgs + console.debug("Issue types not available:", e); + } finally { + setIssueTypesLoading(false); + } + }; + + const loadIssueFields = async () => { + try { + const result = await callTool("ui_get", { method: "issue_fields", owner, repo }); + if (result && !result.isError && result.content) { + const textContent = result.content.find( + (c: { type: string }) => c.type === "text" + ); + if (textContent && "text" in textContent) { + const data = JSON.parse(textContent.text as string); + const fields = (data.fields || []) + .map( + (field: { + id?: string; + name?: string; + data_type?: string; + description?: string; + options?: Array<{ id?: string; name?: string; description?: string; color?: string }>; + }) => ({ + id: String(field.id || field.name || ""), + name: field.name || "", + data_type: field.data_type || "text", + description: field.description || "", + options: (field.options || []) + .map((option) => ({ + id: String(option.id || option.name || ""), + name: option.name || "", + description: option.description || "", + color: option.color || "", + })) + .filter((option) => option.name), + }) + ) + .filter((field: IssueFieldItem) => field.name); + setAvailableIssueFields(fields); + } + } + } catch (e) { + console.debug("Issue fields not available:", e); + setAvailableIssueFields([]); + } + }; + + loadLabels(); + loadAssignees(); + loadMilestones(); + loadIssueTypes(); + loadIssueFields(); + }, [owner, repo, app, callTool]); + + // Track which prefill fields have been applied to avoid re-applying after user edits + const prefillApplied = useRef<{ + title: boolean; + body: boolean; + labels: boolean; + assignees: boolean; + milestone: boolean; + type: boolean; + issueFields: boolean; + }>({ + title: false, + body: false, + labels: false, + assignees: false, + milestone: false, + type: false, + issueFields: false, + }); + + // Store existing issue data for matching when available lists load + interface ExistingIssueData { + labels: string[]; + assignees: string[]; + milestoneNumber: number | null; + issueType: string | null; + fieldValues: unknown; + } + const [existingIssueData, setExistingIssueData] = useState(null); + + // Reset all transient form/result state when toolInput changes (new invocation). + // Without this, the SuccessView from a previous submit stays visible and stale + // form values (e.g. body) bleed through because prefill effects use truthy guards + // that won't overwrite with empty values. The repo is re-initialized from the new + // invocation here (rather than in a separate effect) so it isn't wiped by this reset. + useEffect(() => { + prefillApplied.current = { + title: false, + body: false, + labels: false, + assignees: false, + milestone: false, + type: false, + issueFields: false, + }; + setExistingIssueData(null); + setTitle(""); + setBody(""); + setSelectedLabels([]); + setSelectedAssignees([]); + setSelectedMilestone(null); + setSelectedIssueType(null); + setCurrentState("open"); + setStateReason("completed"); + setDuplicateOf(""); + setPrefilledStateChange(null); + setFieldValues({}); + setSuccessIssue(null); + setError(null); + // Clear available metadata (and filters) so prefill effects, which are gated + // on these lists being non-empty, can't match against the previous repo's data + // before the new repo's ui_get calls resolve. + setAvailableLabels([]); + setAvailableAssignees([]); + setAvailableMilestones([]); + setAvailableIssueTypes([]); + setAvailableIssueFields([]); + setLabelsFilter(""); + setAssigneesFilter(""); + if (toolInput?.owner && toolInput?.repo) { + setSelectedRepo({ + id: `${toolInput.owner}/${toolInput.repo}`, + owner: toolInput.owner as string, + name: toolInput.repo as string, + fullName: `${toolInput.owner}/${toolInput.repo}`, + isPrivate: false, + }); + } else { + setSelectedRepo(null); + } }, [toolInput]); - const handleSubmit = useCallback(async () => { + // Load existing issue data when in update mode + useEffect(() => { + if (!isUpdateMode || !owner || !repo || !issueNumber || !app || existingIssueData !== null) { + return; + } + + const loadExistingIssue = async () => { + try { + const result = await callTool("issue_read", { + method: "get", + owner, + repo, + issue_number: issueNumber, + }); + + if (result && !result.isError && result.content) { + const textContent = result.content.find( + (c) => c.type === "text" + ); + if (textContent && textContent.type === "text" && textContent.text) { + const issueData = JSON.parse(textContent.text); + + const issueState = parseIssueState(issueData.state); + if (issueState) { + setCurrentState(issueState); + } + + // Pre-fill title and body immediately + if (issueData.title && !prefillApplied.current.title) { + setTitle(issueData.title); + prefillApplied.current.title = true; + } + if (issueData.body && !prefillApplied.current.body) { + setBody(issueData.body); + prefillApplied.current.body = true; + } + + // Pre-fill assignees immediately from issue data + const assigneeLogins = (issueData.assignees || []) + .map((a: { login?: string } | string) => typeof a === 'string' ? a : a.login) + .filter(Boolean) as string[]; + if (assigneeLogins.length > 0 && !prefillApplied.current.assignees) { + setSelectedAssignees(assigneeLogins.map(login => ({ id: login, text: login }))); + prefillApplied.current.assignees = true; + } + + // Pre-fill issue type immediately from issue data + const issueTypeName = issueData.type?.name || (typeof issueData.type === 'string' ? issueData.type : null); + if (issueTypeName && !prefillApplied.current.type) { + setSelectedIssueType({ id: issueTypeName, text: issueTypeName }); + prefillApplied.current.type = true; + } + + // Extract data for deferred matching when available lists load (for labels and milestones) + const labelNames = (issueData.labels || []) + .map((l: { name?: string } | string) => typeof l === 'string' ? l : l.name) + .filter(Boolean) as string[]; + + const milestoneNumber = issueData.milestone + ? (typeof issueData.milestone === 'object' ? issueData.milestone.number : issueData.milestone) + : null; + + setExistingIssueData({ + labels: labelNames, + assignees: assigneeLogins, + milestoneNumber, + issueType: issueTypeName, + fieldValues: issueData.field_values || issueData.fieldValues || [], + }); + } + } + } catch (e) { + console.error("Error loading existing issue:", e); + } + }; + + loadExistingIssue(); + }, [isUpdateMode, owner, repo, issueNumber, app, callTool, existingIssueData]); + + // Apply existing labels when available labels load + useEffect(() => { + if (!existingIssueData?.labels.length || !availableLabels.length || prefillApplied.current.labels) return; + const matched = availableLabels.filter((l) => existingIssueData.labels.includes(l.text)); + if (matched.length > 0) { + setSelectedLabels(matched); + prefillApplied.current.labels = true; + } + }, [existingIssueData, availableLabels]); + + // Apply existing milestone when available milestones load + useEffect(() => { + if (!existingIssueData?.milestoneNumber || !availableMilestones.length || prefillApplied.current.milestone) return; + const matched = availableMilestones.find((m) => m.number === existingIssueData.milestoneNumber); + if (matched) { + setSelectedMilestone(matched); + } + prefillApplied.current.milestone = true; + }, [existingIssueData, availableMilestones]); + + // Pre-fill title and body immediately (don't wait for data loading) + useEffect(() => { + if (toolInput?.title && !prefillApplied.current.title) { + setTitle(toolInput.title as string); + prefillApplied.current.title = true; + } + if (toolInput?.body && !prefillApplied.current.body) { + setBody(toolInput.body as string); + prefillApplied.current.body = true; + } + }, [toolInput]); + + // Pre-fill requested state transition controls from tool input + useEffect(() => { + const state = parseIssueState(toolInput?.state); + if (state) { + setPrefilledStateChange(state); + } + + const reason = parseStateReason(toolInput?.state_reason); + if (reason) { + setStateReason(reason); + } + + if (toolInput?.duplicate_of !== undefined && toolInput?.duplicate_of !== null) { + setDuplicateOf(String(toolInput.duplicate_of)); + } + }, [toolInput]); + + // Pre-fill labels once available data is loaded + useEffect(() => { + if ( + toolInput?.labels && + Array.isArray(toolInput.labels) && + availableLabels.length > 0 && + !prefillApplied.current.labels + ) { + const prefillLabels = availableLabels.filter((l) => + (toolInput.labels as string[]).includes(l.text) + ); + if (prefillLabels.length > 0) { + setSelectedLabels(prefillLabels); + prefillApplied.current.labels = true; + } + } + }, [toolInput, availableLabels]); + + // Pre-fill assignees once available data is loaded + useEffect(() => { + if ( + toolInput?.assignees && + Array.isArray(toolInput.assignees) && + availableAssignees.length > 0 && + !prefillApplied.current.assignees + ) { + const prefillAssignees = availableAssignees.filter((a) => + (toolInput.assignees as string[]).includes(a.text) + ); + if (prefillAssignees.length > 0) { + setSelectedAssignees(prefillAssignees); + prefillApplied.current.assignees = true; + } + } + }, [toolInput, availableAssignees]); + + // Pre-fill milestone once available data is loaded + useEffect(() => { + if ( + toolInput?.milestone && + availableMilestones.length > 0 && + !prefillApplied.current.milestone + ) { + const milestone = availableMilestones.find( + (m) => m.number === Number(toolInput.milestone) + ); + if (milestone) { + setSelectedMilestone(milestone); + prefillApplied.current.milestone = true; + } + } + }, [toolInput, availableMilestones]); + + // Pre-fill issue type once available data is loaded + useEffect(() => { + if ( + toolInput?.type && + availableIssueTypes.length > 0 && + !prefillApplied.current.type + ) { + const issueType = availableIssueTypes.find( + (t) => t.text === toolInput.type + ); + if (issueType) { + setSelectedIssueType(issueType); + prefillApplied.current.type = true; + } + } + }, [toolInput, availableIssueTypes]); + + // Pre-fill custom fields once field definitions are loaded + useEffect(() => { + if (!availableIssueFields.length || prefillApplied.current.issueFields) return; + + const toolInputValues = normalizeIssueFieldValues(toolInput?.issue_fields, availableIssueFields); + if (Object.keys(toolInputValues).length > 0) { + setFieldValues(toolInputValues); + prefillApplied.current.issueFields = true; + return; + } + + const existingValues = normalizeIssueFieldValues(existingIssueData?.fieldValues, availableIssueFields); + if (Object.keys(existingValues).length > 0) { + setFieldValues(existingValues); + prefillApplied.current.issueFields = true; + } + }, [toolInput, existingIssueData, availableIssueFields]); + + const issueFieldsByName = useMemo( + () => new Map(availableIssueFields.map((field) => [field.name, field])), + [availableIssueFields] + ); + + const updateIssueFieldValue = useCallback((fieldName: string, value: IssueFieldValue) => { + prefillApplied.current.issueFields = true; + setFieldValues((prev) => ({ ...prev, [fieldName]: value })); + }, []); + + const handleSubmit = useCallback(async (stateChange?: IssueState) => { if (!title.trim()) { setError("Title is required"); return; @@ -157,6 +976,16 @@ function CreateIssueApp() { return; } + const requestedState = isUpdateMode ? stateChange || prefilledStateChange : null; + let duplicateIssueNumber: number | undefined; + if (requestedState === "closed" && stateReason === "duplicate") { + duplicateIssueNumber = Number(duplicateOf); + if (!Number.isInteger(duplicateIssueNumber) || duplicateIssueNumber <= 0) { + setError("Duplicate issue number is required"); + return; + } + } + setIsSubmitting(true); setError(null); @@ -171,10 +1000,60 @@ function CreateIssueApp() { _ui_submitted: true }; + delete params.state; + delete params.state_reason; + delete params.duplicate_of; + delete params.issue_fields; + if (isUpdateMode && issueNumber) { params.issue_number = issueNumber; } + if (selectedLabels.length > 0) { + params.labels = selectedLabels.map((l) => l.text); + } + if (selectedAssignees.length > 0) { + params.assignees = selectedAssignees.map((a) => a.text); + } + if (selectedMilestone) { + params.milestone = selectedMilestone.number; + } + if (selectedIssueType) { + params.type = selectedIssueType.text; + } + + if (requestedState) { + params.state = requestedState; + if (requestedState === "closed") { + params.state_reason = stateReason; + if (stateReason === "duplicate" && duplicateIssueNumber !== undefined) { + params.duplicate_of = duplicateIssueNumber; + } + } + } + + const issueFields = Object.entries(fieldValues) + .map(([fieldName, value]): IssueFieldSubmission | null => { + if (value.cleared) return { field_name: fieldName, delete: true }; + if (value.optionName !== undefined) { + return { field_name: fieldName, field_option_name: value.optionName }; + } + if (value.value !== undefined && value.value !== "") { + const field = issueFieldsByName.get(fieldName); + const fieldValue = + field?.data_type === "number" && typeof value.value === "string" + ? Number(value.value) + : value.value; + if (typeof fieldValue === "number" && Number.isNaN(fieldValue)) return null; + return { field_name: fieldName, value: fieldValue }; + } + return null; + }) + .filter((field): field is IssueFieldSubmission => field !== null); + if (issueFields.length > 0) { + params.issue_fields = issueFields; + } + const result = await callTool("issue_write", params); if (result.isError) { @@ -215,7 +1094,104 @@ function CreateIssueApp() { } finally { setIsSubmitting(false); } - }, [title, body, owner, repo, isUpdateMode, issueNumber, toolInput, callTool, setModelContext]); + }, [ + title, + body, + owner, + repo, + selectedLabels, + selectedAssignees, + selectedMilestone, + selectedIssueType, + isUpdateMode, + issueNumber, + stateReason, + duplicateOf, + prefilledStateChange, + fieldValues, + issueFieldsByName, + toolInput, + callTool, + setModelContext, + ]); + + // Filtered items for dropdowns + const filteredLabels = useMemo(() => { + if (!labelsFilter) return availableLabels; + const lowerFilter = labelsFilter.toLowerCase(); + return availableLabels.filter((l) => + l.text.toLowerCase().includes(lowerFilter) + ); + }, [availableLabels, labelsFilter]); + + const filteredAssignees = useMemo(() => { + if (!assigneesFilter) return availableAssignees; + const lowerFilter = assigneesFilter.toLowerCase(); + return availableAssignees.filter((a) => + a.text.toLowerCase().includes(lowerFilter) + ); + }, [availableAssignees, assigneesFilter]); + + const selectedStateReason = stateReasonOptions.find((option) => option.value === stateReason) || stateReasonOptions[0]; + + const renderIssueFieldInput = (field: IssueFieldItem) => { + const fieldValue = fieldValues[field.name] || {}; + + if (field.data_type === "single_select") { + const selectedOptionName = fieldValue.cleared ? undefined : fieldValue.optionName; + const selectedOption = field.options.find((option) => option.name === selectedOptionName); + return ( + + + + {selectedOption ? selectedOption.name : "Select option"} + + + + {field.options.length === 0 ? ( + No options available + ) : ( + field.options.map((option) => ( + updateIssueFieldValue(field.name, { optionName: option.name })} + > + + + + {option.name} + + )) + )} + + + + + ); + } + + return ( + updateIssueFieldValue(field.name, { value: e.target.value })} + block + contrast + sx={{ flex: 1 }} + /> + ); + }; const body_node = (() => { if (appError) { @@ -241,6 +1217,7 @@ function CreateIssueApp() { owner={owner} repo={repo} submittedTitle={title} + submittedLabels={selectedLabels} isUpdate={isUpdateMode} openLink={openLink} /> @@ -256,7 +1233,7 @@ function CreateIssueApp() { bg="canvas.subtle" p={3} > - {/* Header */} + {/* Repository picker */} - - + + + span:last-child": { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }} + > + {selectedRepo ? selectedRepo.fullName : "Select repository"} + + + + + setRepoFilter(e.target.value)} + sx={{ width: "100%" }} + size="small" + autoFocus + /> + + + {repoSearchLoading ? ( + + + + ) : repoSearchResults.length > 0 ? ( + repoSearchResults.map((r) => ( + { + setSelectedRepo(r); + setRepoFilter(""); + // Clear metadata when switching repos + setAvailableLabels([]); + setSelectedLabels([]); + setAvailableAssignees([]); + setSelectedAssignees([]); + setAvailableMilestones([]); + setSelectedMilestone(null); + setAvailableIssueTypes([]); + setSelectedIssueType(null); + setAvailableIssueFields([]); + setFieldValues({}); + }} + > + + {r.isPrivate ? : } + + {r.fullName} + + )) + ) : selectedRepo ? ( + setRepoFilter("")} + > + + {selectedRepo.isPrivate ? : } + + {selectedRepo.fullName} + + ) : ( + + + Type to search repositories... + + + )} + + + - - {isUpdateMode ? `Update issue #${issueNumber}` : "New issue"} - - - {owner}/{repo} - {/* Error banner */} @@ -314,11 +1358,344 @@ function CreateIssueApp() { /> - {/* Submit button */} - + {/* Metadata section */} + + {/* Labels dropdown */} + + + Labels + {selectedLabels.length > 0 && ( + {selectedLabels.length} + )} + + + + setLabelsFilter(e.target.value)} + size="small" + block + /> + + + {labelsLoading ? ( + + Loading... + + ) : filteredLabels.length === 0 ? ( + No labels available + ) : ( + filteredLabels.map((label) => ( + l.id === label.id)} + onSelect={() => { + setSelectedLabels((prev) => + prev.some((l) => l.id === label.id) + ? prev.filter((l) => l.id !== label.id) + : [...prev, label] + ); + }} + > + + + + {label.text} + + )) + )} + + + + + {/* Assignees dropdown */} + + + Assignees + {selectedAssignees.length > 0 && ( + {selectedAssignees.length} + )} + + + + setAssigneesFilter(e.target.value)} + size="small" + block + /> + + + {assigneesLoading ? ( + + Loading... + + ) : filteredAssignees.length === 0 ? ( + No assignees available + ) : ( + filteredAssignees.map((assignee) => ( + a.id === assignee.id)} + onSelect={() => { + setSelectedAssignees((prev) => + prev.some((a) => a.id === assignee.id) + ? prev.filter((a) => a.id !== assignee.id) + : [...prev, assignee] + ); + }} + > + {assignee.text} + + )) + )} + + + + + {/* Milestones dropdown */} + + + {selectedMilestone ? selectedMilestone.text : "Milestone"} + + + + {milestonesLoading ? ( + + Loading... + + ) : availableMilestones.length === 0 ? ( + No milestones + ) : ( + <> + {selectedMilestone && ( + setSelectedMilestone(null)} + > + Clear selection + + )} + {availableMilestones.map((milestone) => ( + setSelectedMilestone(milestone)} + > + {milestone.text} + {milestone.description && ( + + {milestone.description} + + )} + + ))} + + )} + + + + + {/* Issue Types dropdown */} + + + {selectedIssueType ? selectedIssueType.text : "Type"} + + + + {issueTypesLoading ? ( + + Loading... + + ) : availableIssueTypes.length === 0 ? ( + No issue types + ) : ( + <> + {selectedIssueType && ( + setSelectedIssueType(null)} + > + Clear selection + + )} + {availableIssueTypes.map((type) => ( + setSelectedIssueType(type)} + > + {type.text} + + ))} + + )} + + + + + + {/* Fields section */} + {availableIssueFields.length > 0 && ( + + + Fields + + + {availableIssueFields.map((field) => { + const fieldValue = fieldValues[field.name]; + const hasFieldValue = + fieldValue && + !fieldValue.cleared && + (fieldValue.optionName !== undefined || + (fieldValue.value !== undefined && fieldValue.value !== "")); + + return ( + + + {field.name} + + {field.description && ( + + {field.description} + + )} + + {renderIssueFieldInput(field)} + {hasFieldValue && ( + + )} + + + ); + })} + + + )} + + {/* Selected labels display */} + {selectedLabels.length > 0 && ( + + {selectedLabels.map((label) => ( + + ))} + + )} + + {/* Selected metadata display */} + {(selectedAssignees.length > 0 || selectedMilestone) && ( + + {selectedAssignees.length > 0 && ( + + Assigned to: {selectedAssignees.map((a) => a.text).join(", ")} + + )} + {selectedMilestone && ( + Milestone: {selectedMilestone.text} + )} + + )} + + {/* State and submit actions */} + + {isUpdateMode && ( + + {currentState === "open" ? ( + <> + + + + + {selectedStateReason.label} + + + + {stateReasonOptions.map((option) => ( + setStateReason(option.value)} + > + {option.label} + {option.description} + + ))} + + + + + {stateReason === "duplicate" && ( + + Duplicate of + setDuplicateOf(e.target.value)} + size="small" + sx={{ width: 140 }} + /> + + )} + + ) : ( + + )} + + )} + + + + + + + setIsDraft(e.target.checked)} /> + Mark as draft + + + + + reviewers + + + {selectedReviewers.length === 0 ? ( + "No reviewers" + ) : ( + <> + Reviewers + {selectedReviewers.length} + + )} + + + + setReviewersFilter(e.target.value)} + size="small" + block + /> + + + {reviewersLoading ? ( + Loading... + ) : filteredReviewers.length === 0 ? ( + No reviewers available + ) : ( + filteredReviewers.map((reviewer) => ( + r.id === reviewer.id)} + onSelect={() => { + setSelectedReviewers((prev) => + prev.some((r) => r.id === reviewer.id) + ? prev.filter((r) => r.id !== reviewer.id) + : [...prev, reviewer] + ); + }} + > + + {reviewer.kind === "user" ? ( + reviewer.avatar ? ( + + ) : ( + + ) + ) : ( + + )} + + {reviewer.text} + + )) + )} + + + + {selectedReviewers.length > 0 && ( + + {selectedReviewers.map((reviewer) => ( + + ))} + + )} + + + + + setMaintainerCanModify(e.target.checked)} /> + Allow maintainer edits + + + + + + )} + + + ); +} + +createRoot(document.getElementById("root")!).render( + + + +); diff --git a/ui/src/apps/pr-edit/index.html b/ui/src/apps/pr-edit/index.html new file mode 100644 index 0000000000..9fa60aa992 --- /dev/null +++ b/ui/src/apps/pr-edit/index.html @@ -0,0 +1,12 @@ + + + + + + Edit pull request + + +
+ + + diff --git a/ui/src/apps/pr-write/App.tsx b/ui/src/apps/pr-write/App.tsx index 245753a1bc..769523d41b 100644 --- a/ui/src/apps/pr-write/App.tsx +++ b/ui/src/apps/pr-write/App.tsx @@ -1,4 +1,4 @@ -import { StrictMode, useState, useCallback, useEffect } from "react"; +import { StrictMode, useState, useCallback, useEffect, useMemo } from "react"; import { createRoot } from "react-dom/client"; import { Box, @@ -12,11 +12,18 @@ import { ActionList, Checkbox, ButtonGroup, + CounterLabel, + Label, } from "@primer/react"; import { GitPullRequestIcon, CheckCircleIcon, + RepoIcon, + LockIcon, + GitBranchIcon, TriangleDownIcon, + PersonIcon, + PeopleIcon, } from "@primer/octicons-react"; import { AppProvider } from "../../components/AppProvider"; import { useMcpApp } from "../../hooks/useMcpApp"; @@ -31,6 +38,33 @@ interface PRResult { URL?: string; } +interface RepositoryItem { + id: string; + owner: string; + name: string; + fullName: string; + isPrivate: boolean; +} + +interface BranchItem { + name: string; + protected: boolean; +} + +type ReviewerItem = { kind: "user" | "team"; id: string; text: string; avatar?: string; org?: string }; + +function reviewerFromValue(value: string): ReviewerItem { + if (value.includes("/")) { + const [org, slug] = value.split("/", 2); + return { kind: "team", id: `${org}/${slug}`, text: `${org}/${slug}`, org }; + } + return { kind: "user", id: value, text: value }; +} + +function reviewerValue(reviewer: ReviewerItem): string { + return reviewer.kind === "team" ? reviewer.id : reviewer.text; +} + function SuccessView({ pr, owner, @@ -133,32 +167,231 @@ function CreatePRApp() { const [error, setError] = useState(null); const [successPR, setSuccessPR] = useState(null); + // Branch state + const [availableBranches, setAvailableBranches] = useState([]); + const [baseBranch, setBaseBranch] = useState(""); + const [headBranch, setHeadBranch] = useState(""); + const [branchesLoading, setBranchesLoading] = useState(false); + const [baseFilter, setBaseFilter] = useState(""); + const [headFilter, setHeadFilter] = useState(""); + + // Options const [isDraft, setIsDraft] = useState(false); const [maintainerCanModify, setMaintainerCanModify] = useState(true); + const [availableReviewers, setAvailableReviewers] = useState([]); + const [selectedReviewers, setSelectedReviewers] = useState([]); + const [reviewersLoading, setReviewersLoading] = useState(false); + const [reviewersFilter, setReviewersFilter] = useState(""); + + // Repository state + const [selectedRepo, setSelectedRepo] = useState(null); + const [repoSearchResults, setRepoSearchResults] = useState([]); + const [repoSearchLoading, setRepoSearchLoading] = useState(false); + const [repoFilter, setRepoFilter] = useState(""); const { app, error: appError, toolInput, callTool, hostContext, setModelContext, openLink } = useMcpApp({ appName: "github-mcp-server-create-pull-request", }); - const owner = (toolInput?.owner as string) || ""; - const repo = (toolInput?.repo as string) || ""; - const head = (toolInput?.head as string) || ""; - const base = (toolInput?.base as string) || ""; + const owner = selectedRepo?.owner || (toolInput?.owner as string) || ""; + const repo = selectedRepo?.name || (toolInput?.repo as string) || ""; const [submittedTitle, setSubmittedTitle] = useState(""); + // Reset all transient form/result state when toolInput changes (new invocation). + // Without this, the SuccessView from a previous submit stays visible and stale + // form values bleed through because the prefill effect below only sets when + // toolInput has truthy values and never clears. The repo is re-initialized from + // the new invocation here (rather than in a separate effect) so it isn't wiped + // by this reset. + useEffect(() => { + setTitle(""); + setBody(""); + setHeadBranch(""); + setBaseBranch(""); + setIsDraft(false); + setMaintainerCanModify(true); + setSuccessPR(null); + setError(null); + setSubmittedTitle(""); + // Clear branch list and filters so a new invocation doesn't briefly show stale + // branches from the previous repo (or allow selecting invalid options) before the + // new repo's ui_get branches call resolves. + setAvailableBranches([]); + setBaseFilter(""); + setHeadFilter(""); + setAvailableReviewers([]); + setSelectedReviewers([]); + setReviewersFilter(""); + if (toolInput?.owner && toolInput?.repo) { + setSelectedRepo({ + id: `${toolInput.owner}/${toolInput.repo}`, + owner: toolInput.owner as string, + name: toolInput.repo as string, + fullName: `${toolInput.owner}/${toolInput.repo}`, + isPrivate: false, + }); + } else { + setSelectedRepo(null); + } + }, [toolInput]); + // Pre-fill from toolInput useEffect(() => { if (toolInput?.title) setTitle(toolInput.title as string); if (toolInput?.body) setBody(toolInput.body as string); + if (toolInput?.head) setHeadBranch(toolInput.head as string); + if (toolInput?.base) setBaseBranch(toolInput.base as string); if (toolInput?.draft) setIsDraft(toolInput.draft as boolean); if (toolInput?.maintainer_can_modify !== undefined) { setMaintainerCanModify(toolInput.maintainer_can_modify as boolean); } + if (Array.isArray(toolInput?.reviewers)) { + setSelectedReviewers((toolInput.reviewers as string[]).map(reviewerFromValue)); + } }, [toolInput]); + // Search repositories + useEffect(() => { + if (!app || !repoFilter.trim()) { + setRepoSearchResults([]); + return; + } + + const searchRepos = async () => { + setRepoSearchLoading(true); + try { + const result = await callTool("search_repositories", { query: repoFilter, perPage: 10 }); + if (result && !result.isError && result.content) { + const textContent = result.content.find((c) => c.type === "text"); + if (textContent && textContent.type === "text" && textContent.text) { + const data = JSON.parse(textContent.text); + const repos = (data.repositories || data.items || []).map( + (r: { id?: number; owner?: { login?: string } | string; name?: string; full_name?: string; private?: boolean }) => ({ + id: String(r.id || r.full_name), + owner: typeof r.owner === 'string' ? r.owner : r.owner?.login || r.full_name?.split('/')[0] || '', + name: r.name || '', + fullName: r.full_name || '', + isPrivate: r.private || false, + }) + ); + setRepoSearchResults(repos); + } + } + } catch (e) { + console.error("Failed to search repositories:", e); + } finally { + setRepoSearchLoading(false); + } + }; + + const debounce = setTimeout(searchRepos, 300); + return () => clearTimeout(debounce); + }, [app, callTool, repoFilter]); + + // Load branches and reviewers when repo is selected + useEffect(() => { + if (!owner || !repo || !app) return; + + const loadBranches = async () => { + setBranchesLoading(true); + try { + const result = await callTool("ui_get", { method: "branches", owner, repo }); + if (result && !result.isError && result.content) { + const textContent = result.content.find((c: { type: string }) => c.type === "text"); + if (textContent && "text" in textContent) { + const data = JSON.parse(textContent.text as string); + const branches = (data.branches || data || []).map( + (b: { name: string; protected?: boolean }) => ({ name: b.name, protected: b.protected || false }) + ); + setAvailableBranches(branches); + if (branches.length > 0) { + const defaultBranch = branches.find((b: BranchItem) => b.name === 'main' || b.name === 'master'); + // Functional update so a base branch already prefilled from + // toolInput.base (or chosen by the user) isn't overwritten by a + // stale closure value captured before the request resolved. + if (defaultBranch) setBaseBranch((prev) => prev || defaultBranch.name); + } + } + } + } catch (e) { + console.error("Failed to load branches:", e); + } finally { + setBranchesLoading(false); + } + }; + + const loadReviewers = async () => { + setReviewersLoading(true); + try { + const result = await callTool("ui_get", { method: "reviewers", owner, repo }); + if (result && !result.isError && result.content) { + const textContent = result.content.find((c: { type: string }) => c.type === "text"); + if (textContent && "text" in textContent) { + const data = JSON.parse(textContent.text as string); + const users = (data.users || []).map( + (u: { login: string; avatar_url?: string }) => ({ + kind: "user" as const, + id: u.login, + text: u.login, + avatar: u.avatar_url, + }) + ); + const teams = (data.teams || []).map( + (t: { slug: string; name?: string; org: string }) => ({ + kind: "team" as const, + id: `${t.org}/${t.slug}`, + text: `${t.org}/${t.slug}`, + org: t.org, + }) + ); + setAvailableReviewers([...users, ...teams]); + } + } + } catch (e) { + console.error("Failed to load reviewers:", e); + } finally { + setReviewersLoading(false); + } + }; + + loadBranches(); + loadReviewers(); + }, [owner, repo, app, callTool]); + + useEffect(() => { + if (availableReviewers.length === 0) return; + setSelectedReviewers((prev) => + prev.map((reviewer) => + availableReviewers.find((available) => available.id === reviewer.id || available.text === reviewer.text) || reviewer + ) + ); + }, [availableReviewers]); + + // Filters + const filteredBaseBranches = useMemo(() => { + if (!baseFilter.trim()) return availableBranches; + return availableBranches.filter((b) => b.name.toLowerCase().includes(baseFilter.toLowerCase())); + }, [availableBranches, baseFilter]); + + const filteredHeadBranches = useMemo(() => { + if (!headFilter.trim()) return availableBranches; + return availableBranches.filter((b) => b.name.toLowerCase().includes(headFilter.toLowerCase())); + }, [availableBranches, headFilter]); + + const filteredReviewers = useMemo(() => { + if (!reviewersFilter.trim()) return availableReviewers; + const lowerFilter = reviewersFilter.toLowerCase(); + return availableReviewers.filter((reviewer) => + reviewer.text.toLowerCase().includes(lowerFilter) || reviewer.id.toLowerCase().includes(lowerFilter) + ); + }, [availableReviewers, reviewersFilter]); + const handleSubmit = useCallback(async () => { if (!title.trim()) { setError("Title is required"); return; } if (!owner || !repo) { setError("Repository information not available"); return; } + if (!baseBranch) { setError("Base branch is required"); return; } + if (!headBranch) { setError("Head branch is required"); return; } + if (baseBranch === headBranch) { setError("Base and head branches cannot be the same"); return; } setIsSubmitting(true); setError(null); @@ -170,10 +403,11 @@ function CreatePRApp() { owner, repo, title: title.trim(), body: body.trim(), - head, - base, + head: headBranch, + base: baseBranch, draft: isDraft, maintainer_can_modify: maintainerCanModify, + reviewers: selectedReviewers.map(reviewerValue), _ui_submitted: true }); @@ -204,7 +438,7 @@ function CreatePRApp() { } finally { setIsSubmitting(false); } - }, [title, body, owner, repo, head, base, isDraft, maintainerCanModify, toolInput, callTool, setModelContext]); + }, [title, body, owner, repo, baseBranch, headBranch, isDraft, maintainerCanModify, selectedReviewers, toolInput, callTool, setModelContext]); if (successPR) { return ( @@ -242,7 +476,7 @@ function CreatePRApp() { bg="canvas.subtle" p={3} > - {/* Header */} + {/* Repository picker */} - - + + + span:last-child": { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }} + > + {selectedRepo ? selectedRepo.fullName : "Select repository"} + + + + + setRepoFilter(e.target.value)} + sx={{ width: "100%" }} + size="small" + autoFocus + /> + + + {repoSearchLoading ? ( + + + + ) : repoSearchResults.length > 0 ? ( + repoSearchResults.map((r) => ( + { + setSelectedRepo(r); + setRepoFilter(""); + setAvailableBranches([]); + setBaseBranch(""); + setHeadBranch(""); + setAvailableReviewers([]); + setSelectedReviewers([]); + setReviewersFilter(""); + }} + > + + {r.isPrivate ? : } + + {r.fullName} + + )) + ) : selectedRepo ? ( + setRepoFilter("")}> + + {selectedRepo.isPrivate ? : } + + {selectedRepo.fullName} + + ) : ( + + Type to search repositories... + + )} + + + + + + + {/* Branch selectors */} + + + base + + span": { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }}> + {baseBranch || "Select base"} + + + + + setBaseFilter(e.target.value)} + size="small" + block + /> + + + {branchesLoading ? ( + Loading... + ) : filteredBaseBranches.length === 0 ? ( + No branches found + ) : ( + filteredBaseBranches.map((branch) => ( + { setBaseBranch(branch.name); setBaseFilter(""); }} + > + {branch.name} + {branch.protected && } + + )) + )} + + + + + + + + + compare + + span": { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }}> + {headBranch || "Select head"} + + + + + setHeadFilter(e.target.value)} + size="small" + block + /> + + + {branchesLoading ? ( + Loading... + ) : filteredHeadBranches.length === 0 ? ( + No branches found + ) : ( + filteredHeadBranches.map((branch) => ( + { setHeadBranch(branch.name); setHeadFilter(""); }} + > + {branch.name} + + )) + )} + + + - New pull request - - {owner}/{repo} - - {head && base && ( - - {base} ← {head} - - )} {/* Error banner */} @@ -290,9 +659,93 @@ function CreatePRApp() { + {/* Reviewers */} + + reviewers + + + {selectedReviewers.length === 0 ? ( + "No reviewers" + ) : ( + <> + Reviewers + {selectedReviewers.length} + + )} + + + + setReviewersFilter(e.target.value)} + size="small" + block + /> + + + {reviewersLoading ? ( + Loading... + ) : filteredReviewers.length === 0 ? ( + No reviewers available + ) : ( + filteredReviewers.map((reviewer) => ( + r.id === reviewer.id)} + onSelect={() => { + setSelectedReviewers((prev) => + prev.some((r) => r.id === reviewer.id) + ? prev.filter((r) => r.id !== reviewer.id) + : [...prev, reviewer] + ); + }} + > + + {reviewer.kind === "user" ? ( + reviewer.avatar ? ( + + ) : ( + + ) + ) : ( + + )} + + {reviewer.text} + + )) + )} + + + + {selectedReviewers.length > 0 && ( + + {selectedReviewers.map((reviewer) => ( + + ))} + + )} + + {/* Options and Submit */} - + setMaintainerCanModify(e.target.checked)} /> Allow maintainer edits @@ -301,7 +754,7 @@ function CreatePRApp() {