diff --git a/README.md b/README.md
index b2b8cfafc..8629f3cc0 100644
--- a/README.md
+++ b/README.md
@@ -286,6 +286,7 @@ The following sets of tools are available (all are on by default):
| `code_security` | Code security related tools, such as GitHub Code Scanning |
| `dependabot` | Dependabot tools |
| `discussions` | GitHub Discussions related tools |
+| `enterprise` | GitHub Enterprise related tools |
| `experiments` | Experimental features that are not considered stable yet |
| `gists` | GitHub Gist related tools |
| `issues` | GitHub Issues related tools |
@@ -473,6 +474,28 @@ The following sets of tools are available (all are on by default):
+Enterprise
+
+- **create_enterprise_repository_ruleset** - Create enterprise repository ruleset
+ - `bypass_actors`: The actors that can bypass the rules in this ruleset (object[], optional)
+ - `conditions`: Conditions for when this ruleset applies (object, optional)
+ - `enforcement`: The enforcement level of the ruleset. Can be 'disabled', 'active', or 'evaluate' (string, required)
+ - `enterprise`: Enterprise name (string, required)
+ - `name`: The name of the ruleset (string, required)
+ - `rules`: An array of rules within the ruleset (object[], required)
+ - `target`: The target of the ruleset. Defaults to 'branch'. Can be one of: 'branch', 'tag', or 'push' (string, optional)
+
+- **create_or_update_enterprise_custom_properties** -
+ - `enterprise`: Enterprise name (string, required)
+ - `properties`: Custom properties as JSON array (string, required)
+
+- **get_enterprise_custom_properties** -
+ - `enterprise`: Enterprise name (string, required)
+
+
+
+
+
Gists
- **create_gist** - Create Gist
@@ -632,6 +655,31 @@ The following sets of tools are available (all are on by default):
Organizations
+- **create_or_update_organization_custom_properties** -
+ - `org`: Organization name (string, required)
+ - `properties`: Custom properties as JSON array (string, required)
+
+- **create_organization_repository_ruleset** - Create organization repository ruleset
+ - `bypass_actors`: The actors that can bypass the rules in this ruleset (object[], optional)
+ - `conditions`: Conditions for when this ruleset applies (object, optional)
+ - `enforcement`: The enforcement level of the ruleset. Can be 'disabled', 'active', or 'evaluate' (string, required)
+ - `name`: The name of the ruleset (string, required)
+ - `org`: Organization name (string, required)
+ - `rules`: An array of rules within the ruleset (object[], required)
+ - `target`: The target of the ruleset. Defaults to 'branch'. Can be one of: 'branch', 'tag', or 'push' (string, optional)
+
+- **get_organization_custom_properties** -
+ - `org`: Organization name (string, required)
+
+- **get_organization_repository_ruleset** - Get organization repository ruleset
+ - `org`: Organization name (string, required)
+ - `rulesetId`: Ruleset ID (number, required)
+
+- **list_organization_repository_rulesets** - List organization repository rulesets
+ - `org`: Organization name (string, required)
+ - `page`: Page number for pagination (min 1) (number, optional)
+ - `perPage`: Results per page for pagination (min 1, max 100) (number, optional)
+
- **search_orgs** - Search organizations
- `order`: Sort order (string, optional)
- `page`: Page number for pagination (min 1) (number, optional)
@@ -797,12 +845,27 @@ The following sets of tools are available (all are on by default):
- `repo`: Repository name (string, required)
- `sha`: Required if updating an existing file. The blob SHA of the file being replaced. (string, optional)
+- **create_or_update_repository_custom_properties** -
+ - `owner`: Repository owner (string, required)
+ - `properties`: Custom properties as JSON array (string, required)
+ - `repo`: Repository name (string, required)
+
- **create_repository** - Create repository
- `autoInit`: Initialize with README (boolean, optional)
- `description`: Repository description (string, optional)
- `name`: Repository name (string, required)
- `private`: Whether repo should be private (boolean, optional)
+- **create_repository_ruleset** - Create repository ruleset
+ - `bypass_actors`: The actors that can bypass the rules in this ruleset (object[], optional)
+ - `conditions`: Conditions for when this ruleset applies (object, optional)
+ - `enforcement`: The enforcement level of the ruleset. Can be 'disabled', 'active', or 'evaluate' (string, required)
+ - `name`: The name of the ruleset (string, required)
+ - `owner`: Repository owner (string, required)
+ - `repo`: Repository name (string, required)
+ - `rules`: An array of rules within the ruleset (object[], required)
+ - `target`: The target of the ruleset. Defaults to 'branch'. Can be one of: 'branch', 'tag', or 'push' (string, optional)
+
- **delete_file** - Delete file
- `branch`: Branch to delete the file from (string, required)
- `message`: Commit message (string, required)
@@ -829,6 +892,28 @@ The following sets of tools are available (all are on by default):
- `repo`: Repository name (string, required)
- `sha`: Accepts optional commit SHA. If specified, it will be used instead of ref (string, optional)
+- **get_repository_custom_properties** -
+ - `owner`: Repository owner (string, required)
+ - `repo`: Repository name (string, required)
+
+- **get_repository_rule_suite** - Get repository rule suite
+ - `owner`: Repository owner (string, required)
+ - `repo`: Repository name (string, required)
+ - `ruleSuiteId`: Rule suite ID (number, required)
+
+- **get_repository_rules_for_branch** - Get rules for branch
+ - `branch`: Branch name (string, required)
+ - `owner`: Repository owner (string, required)
+ - `page`: Page number for pagination (min 1) (number, optional)
+ - `perPage`: Results per page for pagination (min 1, max 100) (number, optional)
+ - `repo`: Repository name (string, required)
+
+- **get_repository_ruleset** - Get repository ruleset
+ - `includesParents`: Include rulesets configured at higher levels that also apply (boolean, optional)
+ - `owner`: Repository owner (string, required)
+ - `repo`: Repository name (string, required)
+ - `rulesetId`: Ruleset ID (number, required)
+
- **get_tag** - Get tag details
- `owner`: Repository owner (string, required)
- `repo`: Repository name (string, required)
@@ -848,6 +933,21 @@ The following sets of tools are available (all are on by default):
- `repo`: Repository name (string, required)
- `sha`: Commit SHA, branch or tag name to list commits of. If not provided, uses the default branch of the repository. If a commit SHA is provided, will list commits up to that SHA. (string, optional)
+- **list_repository_rule_suites** - List repository rule suites
+ - `actorName`: The handle for the GitHub user account to filter on (string, optional)
+ - `owner`: Repository owner (string, required)
+ - `page`: Page number for pagination (min 1) (number, optional)
+ - `perPage`: Results per page for pagination (min 1, max 100) (number, optional)
+ - `ref`: The name of the ref (branch, tag, etc.) to filter rule suites by (string, optional)
+ - `repo`: Repository name (string, required)
+ - `ruleSuiteResult`: The rule suite result to filter by. Options: pass, fail, bypass (string, optional)
+ - `timePeriod`: The time period to filter by. Options: hour, day, week, month (string, optional)
+
+- **list_repository_rulesets** - List repository rulesets
+ - `includesParents`: Include rulesets configured at higher levels that also apply (boolean, optional)
+ - `owner`: Repository owner (string, required)
+ - `repo`: Repository name (string, required)
+
- **list_tags** - List tags
- `owner`: Repository owner (string, required)
- `page`: Page number for pagination (min 1) (number, optional)
diff --git a/docs/remote-server.md b/docs/remote-server.md
index 5f57f4961..b840df40e 100644
--- a/docs/remote-server.md
+++ b/docs/remote-server.md
@@ -24,6 +24,7 @@ Below is a table of available toolsets for the remote GitHub MCP Server. Each to
| 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) |
| 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) |
| Discussions | GitHub Discussions related tools | https://api.githubcopilot.com/mcp/x/discussions | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-discussions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdiscussions%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/discussions/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-discussions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdiscussions%2Freadonly%22%7D) |
+| Enterprise | GitHub Enterprise related tools | https://api.githubcopilot.com/mcp/x/enterprise | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-enterprise&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fenterprise%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/enterprise/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-enterprise&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fenterprise%2Freadonly%22%7D) |
| Experiments | Experimental features that are not considered stable yet | https://api.githubcopilot.com/mcp/x/experiments | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-experiments&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fexperiments%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/experiments/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-experiments&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fexperiments%2Freadonly%22%7D) |
| Gists | GitHub Gist related tools | https://api.githubcopilot.com/mcp/x/gists | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-gists&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgists%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/gists/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-gists&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgists%2Freadonly%22%7D) |
| Issues | GitHub Issues related tools | https://api.githubcopilot.com/mcp/x/issues | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-issues&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fissues%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/issues/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-issues&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fissues%2Freadonly%22%7D) |
diff --git a/pkg/github/__toolsnaps__/get_organization_repository_ruleset.snap b/pkg/github/__toolsnaps__/get_organization_repository_ruleset.snap
new file mode 100644
index 000000000..97c39dc97
--- /dev/null
+++ b/pkg/github/__toolsnaps__/get_organization_repository_ruleset.snap
@@ -0,0 +1,25 @@
+{
+ "annotations": {
+ "title": "Get organization repository ruleset",
+ "readOnlyHint": true
+ },
+ "description": "Get details of a specific organization repository ruleset",
+ "inputSchema": {
+ "properties": {
+ "org": {
+ "description": "Organization name",
+ "type": "string"
+ },
+ "rulesetId": {
+ "description": "Ruleset ID",
+ "type": "number"
+ }
+ },
+ "required": [
+ "org",
+ "rulesetId"
+ ],
+ "type": "object"
+ },
+ "name": "get_organization_repository_ruleset"
+}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/get_repository_rule_suite.snap b/pkg/github/__toolsnaps__/get_repository_rule_suite.snap
new file mode 100644
index 000000000..7f3877d11
--- /dev/null
+++ b/pkg/github/__toolsnaps__/get_repository_rule_suite.snap
@@ -0,0 +1,30 @@
+{
+ "annotations": {
+ "title": "Get repository rule suite",
+ "readOnlyHint": true
+ },
+ "description": "Get details of a specific repository rule suite",
+ "inputSchema": {
+ "properties": {
+ "owner": {
+ "description": "Repository owner",
+ "type": "string"
+ },
+ "repo": {
+ "description": "Repository name",
+ "type": "string"
+ },
+ "ruleSuiteId": {
+ "description": "Rule suite ID",
+ "type": "number"
+ }
+ },
+ "required": [
+ "owner",
+ "repo",
+ "ruleSuiteId"
+ ],
+ "type": "object"
+ },
+ "name": "get_repository_rule_suite"
+}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/get_repository_rules_for_branch.snap b/pkg/github/__toolsnaps__/get_repository_rules_for_branch.snap
new file mode 100644
index 000000000..e9fa62ad2
--- /dev/null
+++ b/pkg/github/__toolsnaps__/get_repository_rules_for_branch.snap
@@ -0,0 +1,41 @@
+{
+ "annotations": {
+ "title": "Get rules for branch",
+ "readOnlyHint": true
+ },
+ "description": "Get all repository rules that apply to a specific branch",
+ "inputSchema": {
+ "properties": {
+ "branch": {
+ "description": "Branch name",
+ "type": "string"
+ },
+ "owner": {
+ "description": "Repository owner",
+ "type": "string"
+ },
+ "page": {
+ "description": "Page number for pagination (min 1)",
+ "minimum": 1,
+ "type": "number"
+ },
+ "perPage": {
+ "description": "Results per page for pagination (min 1, max 100)",
+ "maximum": 100,
+ "minimum": 1,
+ "type": "number"
+ },
+ "repo": {
+ "description": "Repository name",
+ "type": "string"
+ }
+ },
+ "required": [
+ "owner",
+ "repo",
+ "branch"
+ ],
+ "type": "object"
+ },
+ "name": "get_repository_rules_for_branch"
+}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/get_repository_ruleset.snap b/pkg/github/__toolsnaps__/get_repository_ruleset.snap
new file mode 100644
index 000000000..83a1c99d0
--- /dev/null
+++ b/pkg/github/__toolsnaps__/get_repository_ruleset.snap
@@ -0,0 +1,34 @@
+{
+ "annotations": {
+ "title": "Get repository ruleset",
+ "readOnlyHint": true
+ },
+ "description": "Get details of a specific repository ruleset",
+ "inputSchema": {
+ "properties": {
+ "includesParents": {
+ "description": "Include rulesets configured at higher levels that also apply",
+ "type": "boolean"
+ },
+ "owner": {
+ "description": "Repository owner",
+ "type": "string"
+ },
+ "repo": {
+ "description": "Repository name",
+ "type": "string"
+ },
+ "rulesetId": {
+ "description": "Ruleset ID",
+ "type": "number"
+ }
+ },
+ "required": [
+ "owner",
+ "repo",
+ "rulesetId"
+ ],
+ "type": "object"
+ },
+ "name": "get_repository_ruleset"
+}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/list_organization_repository_rulesets.snap b/pkg/github/__toolsnaps__/list_organization_repository_rulesets.snap
new file mode 100644
index 000000000..4b3f6c591
--- /dev/null
+++ b/pkg/github/__toolsnaps__/list_organization_repository_rulesets.snap
@@ -0,0 +1,31 @@
+{
+ "annotations": {
+ "title": "List organization repository rulesets",
+ "readOnlyHint": true
+ },
+ "description": "List all organization repository rulesets",
+ "inputSchema": {
+ "properties": {
+ "org": {
+ "description": "Organization name",
+ "type": "string"
+ },
+ "page": {
+ "description": "Page number for pagination (min 1)",
+ "minimum": 1,
+ "type": "number"
+ },
+ "perPage": {
+ "description": "Results per page for pagination (min 1, max 100)",
+ "maximum": 100,
+ "minimum": 1,
+ "type": "number"
+ }
+ },
+ "required": [
+ "org"
+ ],
+ "type": "object"
+ },
+ "name": "list_organization_repository_rulesets"
+}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/list_repository_rule_suites.snap b/pkg/github/__toolsnaps__/list_repository_rule_suites.snap
new file mode 100644
index 000000000..6508f914b
--- /dev/null
+++ b/pkg/github/__toolsnaps__/list_repository_rule_suites.snap
@@ -0,0 +1,52 @@
+{
+ "annotations": {
+ "title": "List repository rule suites",
+ "readOnlyHint": true
+ },
+ "description": "List rule suites for a repository",
+ "inputSchema": {
+ "properties": {
+ "actorName": {
+ "description": "The handle for the GitHub user account to filter on",
+ "type": "string"
+ },
+ "owner": {
+ "description": "Repository owner",
+ "type": "string"
+ },
+ "page": {
+ "description": "Page number for pagination (min 1)",
+ "minimum": 1,
+ "type": "number"
+ },
+ "perPage": {
+ "description": "Results per page for pagination (min 1, max 100)",
+ "maximum": 100,
+ "minimum": 1,
+ "type": "number"
+ },
+ "ref": {
+ "description": "The name of the ref (branch, tag, etc.) to filter rule suites by",
+ "type": "string"
+ },
+ "repo": {
+ "description": "Repository name",
+ "type": "string"
+ },
+ "ruleSuiteResult": {
+ "description": "The rule suite result to filter by. Options: pass, fail, bypass",
+ "type": "string"
+ },
+ "timePeriod": {
+ "description": "The time period to filter by. Options: hour, day, week, month",
+ "type": "string"
+ }
+ },
+ "required": [
+ "owner",
+ "repo"
+ ],
+ "type": "object"
+ },
+ "name": "list_repository_rule_suites"
+}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/list_repository_rulesets.snap b/pkg/github/__toolsnaps__/list_repository_rulesets.snap
new file mode 100644
index 000000000..8b4b15eab
--- /dev/null
+++ b/pkg/github/__toolsnaps__/list_repository_rulesets.snap
@@ -0,0 +1,29 @@
+{
+ "annotations": {
+ "title": "List repository rulesets",
+ "readOnlyHint": true
+ },
+ "description": "List all repository rulesets",
+ "inputSchema": {
+ "properties": {
+ "includesParents": {
+ "description": "Include rulesets configured at higher levels that also apply",
+ "type": "boolean"
+ },
+ "owner": {
+ "description": "Repository owner",
+ "type": "string"
+ },
+ "repo": {
+ "description": "Repository name",
+ "type": "string"
+ }
+ },
+ "required": [
+ "owner",
+ "repo"
+ ],
+ "type": "object"
+ },
+ "name": "list_repository_rulesets"
+}
\ No newline at end of file
diff --git a/pkg/github/custom_properties.go b/pkg/github/custom_properties.go
new file mode 100644
index 000000000..86c93df30
--- /dev/null
+++ b/pkg/github/custom_properties.go
@@ -0,0 +1,232 @@
+package github
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+
+ "github.com/github/github-mcp-server/pkg/translations"
+ "github.com/google/go-github/v73/github"
+ "github.com/mark3labs/mcp-go/mcp"
+ "github.com/mark3labs/mcp-go/server"
+)
+
+func GetRepositoryCustomProperties(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
+ return mcp.NewTool("get_repository_custom_properties",
+ mcp.WithDescription(t("TOOL_GET_REPOSITORY_CUSTOM_PROPERTIES_DESCRIPTION", "Get custom properties for a repository")),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ ReadOnlyHint: ToBoolPtr(true),
+ }),
+ mcp.WithString("owner",
+ mcp.Required(),
+ mcp.Description("Repository owner"),
+ ),
+ mcp.WithString("repo",
+ mcp.Required(),
+ mcp.Description("Repository name"),
+ ),
+ ),
+ func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ owner, err := RequiredParam[string](request, "owner")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ repo, err := RequiredParam[string](request, "repo")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+
+ client, err := getClient(ctx)
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+
+ req, err := client.NewRequest("GET", fmt.Sprintf("repos/%s/%s/properties/values", owner, repo), nil)
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+
+ var props []*github.CustomProperty
+ _, err = client.Do(ctx, req, &props)
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+
+ return MarshalledTextResult(props), nil
+ }
+}
+
+func CreateOrUpdateRepositoryCustomProperties(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
+ return mcp.NewTool(
+ "create_or_update_repository_custom_properties",
+ mcp.WithDescription(t("GITHUB_CREATE_OR_UPDATE_REPOSITORY_CUSTOM_PROPERTIES_DESCRIPTION", "Create or update repository custom properties")),
+ mcp.WithString("owner", mcp.Required(), mcp.Description("Repository owner")),
+ mcp.WithString("repo", mcp.Required(), mcp.Description("Repository name")),
+ mcp.WithString("properties", mcp.Required(), mcp.Description("Custom properties as JSON array")),
+ ),
+ func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ owner, err := RequiredParam[string](request, "owner")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ repo, err := RequiredParam[string](request, "repo")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ propertiesStr, err := RequiredParam[string](request, "properties")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ var props []*github.CustomProperty
+ if err := json.Unmarshal([]byte(propertiesStr), &props); err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ client, err := getClient(ctx)
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ req, err := client.NewRequest("PATCH", fmt.Sprintf("repos/%s/%s/properties/values", owner, repo), map[string]interface{}{"properties": props})
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ _, err = client.Do(ctx, req, nil)
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ return mcp.NewToolResultText("Custom properties updated successfully"), nil
+ }
+}
+
+func GetOrganizationCustomProperties(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
+ return mcp.NewTool(
+ "get_organization_custom_properties",
+ mcp.WithDescription(t("GITHUB_GET_ORGANIZATION_CUSTOM_PROPERTIES_DESCRIPTION", "Get organization custom properties")),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ ReadOnlyHint: ToBoolPtr(true),
+ }),
+ mcp.WithString("org", mcp.Required(), mcp.Description("Organization name")),
+ ),
+ func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ org, err := RequiredParam[string](request, "org")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ client, err := getClient(ctx)
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ req, err := client.NewRequest("GET", fmt.Sprintf("orgs/%s/properties/schema", org), nil)
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ var props []*github.CustomProperty
+ _, err = client.Do(ctx, req, &props)
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ return MarshalledTextResult(props), nil
+ }
+}
+
+func CreateOrUpdateOrganizationCustomProperties(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
+ return mcp.NewTool(
+ "create_or_update_organization_custom_properties",
+ mcp.WithDescription(t("GITHUB_CREATE_OR_UPDATE_ORGANIZATION_CUSTOM_PROPERTIES_DESCRIPTION", "Create or update organization custom properties")),
+ mcp.WithString("org", mcp.Required(), mcp.Description("Organization name")),
+ mcp.WithString("properties", mcp.Required(), mcp.Description("Custom properties as JSON array")),
+ ),
+ func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ org, err := RequiredParam[string](request, "org")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ propertiesStr, err := RequiredParam[string](request, "properties")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ var props []*github.CustomProperty
+ if err := json.Unmarshal([]byte(propertiesStr), &props); err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ client, err := getClient(ctx)
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ req, err := client.NewRequest("PATCH", fmt.Sprintf("orgs/%s/properties/schema", org), map[string]interface{}{"properties": props})
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ _, err = client.Do(ctx, req, nil)
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ return mcp.NewToolResultText("Custom properties updated successfully"), nil
+ }
+}
+
+func GetEnterpriseCustomProperties(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
+ return mcp.NewTool(
+ "get_enterprise_custom_properties",
+ mcp.WithDescription(t("GITHUB_GET_ENTERPRISE_CUSTOM_PROPERTIES_DESCRIPTION", "Get enterprise custom properties")),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ ReadOnlyHint: ToBoolPtr(true),
+ }),
+ mcp.WithString("enterprise", mcp.Required(), mcp.Description("Enterprise name")),
+ ),
+ func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ enterprise, err := RequiredParam[string](request, "enterprise")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ client, err := getClient(ctx)
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ req, err := client.NewRequest("GET", fmt.Sprintf("enterprises/%s/properties/schema", enterprise), nil)
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ var props []*github.CustomProperty
+ _, err = client.Do(ctx, req, &props)
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ return MarshalledTextResult(props), nil
+ }
+}
+
+func CreateOrUpdateEnterpriseCustomProperties(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
+ return mcp.NewTool(
+ "create_or_update_enterprise_custom_properties",
+ mcp.WithDescription(t("GITHUB_CREATE_OR_UPDATE_ENTERPRISE_CUSTOM_PROPERTIES_DESCRIPTION", "Create or update enterprise custom properties")),
+ mcp.WithString("enterprise", mcp.Required(), mcp.Description("Enterprise name")),
+ mcp.WithString("properties", mcp.Required(), mcp.Description("Custom properties as JSON array")),
+ ),
+ func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ enterprise, err := RequiredParam[string](request, "enterprise")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ propertiesStr, err := RequiredParam[string](request, "properties")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ var props []*github.CustomProperty
+ if err := json.Unmarshal([]byte(propertiesStr), &props); err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ client, err := getClient(ctx)
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ req, err := client.NewRequest("PATCH", fmt.Sprintf("enterprises/%s/properties/schema", enterprise), map[string]interface{}{"properties": props})
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ _, err = client.Do(ctx, req, nil)
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ return mcp.NewToolResultText("Custom properties updated successfully"), nil
+ }
+}
diff --git a/pkg/github/discussions_test.go b/pkg/github/discussions_test.go
deleted file mode 100644
index b9bc7f611..000000000
--- a/pkg/github/discussions_test.go
+++ /dev/null
@@ -1,778 +0,0 @@
-package github
-
-import (
- "context"
- "encoding/json"
- "net/http"
- "testing"
- "time"
-
- "github.com/github/github-mcp-server/internal/githubv4mock"
- "github.com/github/github-mcp-server/pkg/translations"
- "github.com/google/go-github/v73/github"
- "github.com/shurcooL/githubv4"
- "github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
-)
-
-var (
- discussionsGeneral = []map[string]any{
- {"number": 1, "title": "Discussion 1 title", "createdAt": "2023-01-01T00:00:00Z", "updatedAt": "2023-01-01T00:00:00Z", "author": map[string]any{"login": "user1"}, "url": "https://github.com/owner/repo/discussions/1", "category": map[string]any{"name": "General"}},
- {"number": 3, "title": "Discussion 3 title", "createdAt": "2023-03-01T00:00:00Z", "updatedAt": "2023-02-01T00:00:00Z", "author": map[string]any{"login": "user1"}, "url": "https://github.com/owner/repo/discussions/3", "category": map[string]any{"name": "General"}},
- }
- discussionsAll = []map[string]any{
- {
- "number": 1,
- "title": "Discussion 1 title",
- "createdAt": "2023-01-01T00:00:00Z",
- "updatedAt": "2023-01-01T00:00:00Z",
- "author": map[string]any{"login": "user1"},
- "url": "https://github.com/owner/repo/discussions/1",
- "category": map[string]any{"name": "General"},
- },
- {
- "number": 2,
- "title": "Discussion 2 title",
- "createdAt": "2023-02-01T00:00:00Z",
- "updatedAt": "2023-02-01T00:00:00Z",
- "author": map[string]any{"login": "user2"},
- "url": "https://github.com/owner/repo/discussions/2",
- "category": map[string]any{"name": "Questions"},
- },
- {
- "number": 3,
- "title": "Discussion 3 title",
- "createdAt": "2023-03-01T00:00:00Z",
- "updatedAt": "2023-03-01T00:00:00Z",
- "author": map[string]any{"login": "user3"},
- "url": "https://github.com/owner/repo/discussions/3",
- "category": map[string]any{"name": "General"},
- },
- }
-
- discussionsOrgLevel = []map[string]any{
- {
- "number": 1,
- "title": "Org Discussion 1 - Community Guidelines",
- "createdAt": "2023-01-15T00:00:00Z",
- "updatedAt": "2023-01-15T00:00:00Z",
- "author": map[string]any{"login": "org-admin"},
- "url": "https://github.com/owner/.github/discussions/1",
- "category": map[string]any{"name": "Announcements"},
- },
- {
- "number": 2,
- "title": "Org Discussion 2 - Roadmap 2023",
- "createdAt": "2023-02-20T00:00:00Z",
- "updatedAt": "2023-02-20T00:00:00Z",
- "author": map[string]any{"login": "org-admin"},
- "url": "https://github.com/owner/.github/discussions/2",
- "category": map[string]any{"name": "General"},
- },
- {
- "number": 3,
- "title": "Org Discussion 3 - Roadmap 2024",
- "createdAt": "2023-02-20T00:00:00Z",
- "updatedAt": "2023-02-20T00:00:00Z",
- "author": map[string]any{"login": "org-admin"},
- "url": "https://github.com/owner/.github/discussions/3",
- "category": map[string]any{"name": "General"},
- },
- {
- "number": 4,
- "title": "Org Discussion 4 - Roadmap 2025",
- "createdAt": "2023-02-20T00:00:00Z",
- "updatedAt": "2023-02-20T00:00:00Z",
- "author": map[string]any{"login": "org-admin"},
- "url": "https://github.com/owner/.github/discussions/4",
- "category": map[string]any{"name": "General"},
- },
- }
-
- // Ordered mock responses
- discussionsOrderedCreatedAsc = []map[string]any{
- discussionsAll[0], // Discussion 1 (created 2023-01-01)
- discussionsAll[1], // Discussion 2 (created 2023-02-01)
- discussionsAll[2], // Discussion 3 (created 2023-03-01)
- }
-
- discussionsOrderedUpdatedDesc = []map[string]any{
- discussionsAll[2], // Discussion 3 (updated 2023-03-01)
- discussionsAll[1], // Discussion 2 (updated 2023-02-01)
- discussionsAll[0], // Discussion 1 (updated 2023-01-01)
- }
-
- // only 'General' category discussions ordered by created date descending
- discussionsGeneralOrderedDesc = []map[string]any{
- discussionsGeneral[1], // Discussion 3 (created 2023-03-01)
- discussionsGeneral[0], // Discussion 1 (created 2023-01-01)
- }
-
- mockResponseListAll = githubv4mock.DataResponse(map[string]any{
- "repository": map[string]any{
- "discussions": map[string]any{
- "nodes": discussionsAll,
- "pageInfo": map[string]any{
- "hasNextPage": false,
- "hasPreviousPage": false,
- "startCursor": "",
- "endCursor": "",
- },
- "totalCount": 3,
- },
- },
- })
- mockResponseListGeneral = githubv4mock.DataResponse(map[string]any{
- "repository": map[string]any{
- "discussions": map[string]any{
- "nodes": discussionsGeneral,
- "pageInfo": map[string]any{
- "hasNextPage": false,
- "hasPreviousPage": false,
- "startCursor": "",
- "endCursor": "",
- },
- "totalCount": 2,
- },
- },
- })
- mockResponseOrderedCreatedAsc = githubv4mock.DataResponse(map[string]any{
- "repository": map[string]any{
- "discussions": map[string]any{
- "nodes": discussionsOrderedCreatedAsc,
- "pageInfo": map[string]any{
- "hasNextPage": false,
- "hasPreviousPage": false,
- "startCursor": "",
- "endCursor": "",
- },
- "totalCount": 3,
- },
- },
- })
- mockResponseOrderedUpdatedDesc = githubv4mock.DataResponse(map[string]any{
- "repository": map[string]any{
- "discussions": map[string]any{
- "nodes": discussionsOrderedUpdatedDesc,
- "pageInfo": map[string]any{
- "hasNextPage": false,
- "hasPreviousPage": false,
- "startCursor": "",
- "endCursor": "",
- },
- "totalCount": 3,
- },
- },
- })
- mockResponseGeneralOrderedDesc = githubv4mock.DataResponse(map[string]any{
- "repository": map[string]any{
- "discussions": map[string]any{
- "nodes": discussionsGeneralOrderedDesc,
- "pageInfo": map[string]any{
- "hasNextPage": false,
- "hasPreviousPage": false,
- "startCursor": "",
- "endCursor": "",
- },
- "totalCount": 2,
- },
- },
- })
-
- mockResponseOrgLevel = githubv4mock.DataResponse(map[string]any{
- "repository": map[string]any{
- "discussions": map[string]any{
- "nodes": discussionsOrgLevel,
- "pageInfo": map[string]any{
- "hasNextPage": false,
- "hasPreviousPage": false,
- "startCursor": "",
- "endCursor": "",
- },
- "totalCount": 4,
- },
- },
- })
-
- mockErrorRepoNotFound = githubv4mock.ErrorResponse("repository not found")
-)
-
-func Test_ListDiscussions(t *testing.T) {
- mockClient := githubv4.NewClient(nil)
- toolDef, _ := ListDiscussions(stubGetGQLClientFn(mockClient), translations.NullTranslationHelper)
- assert.Equal(t, "list_discussions", toolDef.Name)
- assert.NotEmpty(t, toolDef.Description)
- assert.Contains(t, toolDef.InputSchema.Properties, "owner")
- assert.Contains(t, toolDef.InputSchema.Properties, "repo")
- assert.Contains(t, toolDef.InputSchema.Properties, "orderBy")
- assert.Contains(t, toolDef.InputSchema.Properties, "direction")
- assert.ElementsMatch(t, toolDef.InputSchema.Required, []string{"owner"})
-
- // Variables matching what GraphQL receives after JSON marshaling/unmarshaling
- varsListAll := map[string]interface{}{
- "owner": "owner",
- "repo": "repo",
- "first": float64(30),
- "after": (*string)(nil),
- }
-
- varsRepoNotFound := map[string]interface{}{
- "owner": "owner",
- "repo": "nonexistent-repo",
- "first": float64(30),
- "after": (*string)(nil),
- }
-
- varsDiscussionsFiltered := map[string]interface{}{
- "owner": "owner",
- "repo": "repo",
- "categoryId": "DIC_kwDOABC123",
- "first": float64(30),
- "after": (*string)(nil),
- }
-
- varsOrderByCreatedAsc := map[string]interface{}{
- "owner": "owner",
- "repo": "repo",
- "orderByField": "CREATED_AT",
- "orderByDirection": "ASC",
- "first": float64(30),
- "after": (*string)(nil),
- }
-
- varsOrderByUpdatedDesc := map[string]interface{}{
- "owner": "owner",
- "repo": "repo",
- "orderByField": "UPDATED_AT",
- "orderByDirection": "DESC",
- "first": float64(30),
- "after": (*string)(nil),
- }
-
- varsCategoryWithOrder := map[string]interface{}{
- "owner": "owner",
- "repo": "repo",
- "categoryId": "DIC_kwDOABC123",
- "orderByField": "CREATED_AT",
- "orderByDirection": "DESC",
- "first": float64(30),
- "after": (*string)(nil),
- }
-
- varsOrgLevel := map[string]interface{}{
- "owner": "owner",
- "repo": ".github", // This is what gets set when repo is not provided
- "first": float64(30),
- "after": (*string)(nil),
- }
-
- tests := []struct {
- name string
- reqParams map[string]interface{}
- expectError bool
- errContains string
- expectedCount int
- verifyOrder func(t *testing.T, discussions []*github.Discussion)
- }{
- {
- name: "list all discussions without category filter",
- reqParams: map[string]interface{}{
- "owner": "owner",
- "repo": "repo",
- },
- expectError: false,
- expectedCount: 3, // All discussions
- },
- {
- name: "filter by category ID",
- reqParams: map[string]interface{}{
- "owner": "owner",
- "repo": "repo",
- "category": "DIC_kwDOABC123",
- },
- expectError: false,
- expectedCount: 2, // Only General discussions (matching the category ID)
- },
- {
- name: "order by created at ascending",
- reqParams: map[string]interface{}{
- "owner": "owner",
- "repo": "repo",
- "orderBy": "CREATED_AT",
- "direction": "ASC",
- },
- expectError: false,
- expectedCount: 3,
- verifyOrder: func(t *testing.T, discussions []*github.Discussion) {
- // Verify discussions are ordered by created date ascending
- require.Len(t, discussions, 3)
- assert.Equal(t, 1, *discussions[0].Number, "First should be discussion 1 (created 2023-01-01)")
- assert.Equal(t, 2, *discussions[1].Number, "Second should be discussion 2 (created 2023-02-01)")
- assert.Equal(t, 3, *discussions[2].Number, "Third should be discussion 3 (created 2023-03-01)")
- },
- },
- {
- name: "order by updated at descending",
- reqParams: map[string]interface{}{
- "owner": "owner",
- "repo": "repo",
- "orderBy": "UPDATED_AT",
- "direction": "DESC",
- },
- expectError: false,
- expectedCount: 3,
- verifyOrder: func(t *testing.T, discussions []*github.Discussion) {
- // Verify discussions are ordered by updated date descending
- require.Len(t, discussions, 3)
- assert.Equal(t, 3, *discussions[0].Number, "First should be discussion 3 (updated 2023-03-01)")
- assert.Equal(t, 2, *discussions[1].Number, "Second should be discussion 2 (updated 2023-02-01)")
- assert.Equal(t, 1, *discussions[2].Number, "Third should be discussion 1 (updated 2023-01-01)")
- },
- },
- {
- name: "filter by category with order",
- reqParams: map[string]interface{}{
- "owner": "owner",
- "repo": "repo",
- "category": "DIC_kwDOABC123",
- "orderBy": "CREATED_AT",
- "direction": "DESC",
- },
- expectError: false,
- expectedCount: 2,
- verifyOrder: func(t *testing.T, discussions []*github.Discussion) {
- // Verify only General discussions, ordered by created date descending
- require.Len(t, discussions, 2)
- assert.Equal(t, 3, *discussions[0].Number, "First should be discussion 3 (created 2023-03-01)")
- assert.Equal(t, 1, *discussions[1].Number, "Second should be discussion 1 (created 2023-01-01)")
- },
- },
- {
- name: "order by without direction (should not use ordering)",
- reqParams: map[string]interface{}{
- "owner": "owner",
- "repo": "repo",
- "orderBy": "CREATED_AT",
- },
- expectError: false,
- expectedCount: 3,
- },
- {
- name: "direction without order by (should not use ordering)",
- reqParams: map[string]interface{}{
- "owner": "owner",
- "repo": "repo",
- "direction": "DESC",
- },
- expectError: false,
- expectedCount: 3,
- },
- {
- name: "repository not found error",
- reqParams: map[string]interface{}{
- "owner": "owner",
- "repo": "nonexistent-repo",
- },
- expectError: true,
- errContains: "repository not found",
- },
- {
- name: "list org-level discussions (no repo provided)",
- reqParams: map[string]interface{}{
- "owner": "owner",
- // repo is not provided, it will default to ".github"
- },
- expectError: false,
- expectedCount: 4,
- },
- }
-
- // Define the actual query strings that match the implementation
- qBasicNoOrder := "query($after:String$first:Int!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){discussions(first: $first, after: $after){nodes{number,title,createdAt,updatedAt,author{login},category{name},url},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}"
- qWithCategoryNoOrder := "query($after:String$categoryId:ID!$first:Int!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){discussions(first: $first, after: $after, categoryId: $categoryId){nodes{number,title,createdAt,updatedAt,author{login},category{name},url},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}"
- qBasicWithOrder := "query($after:String$first:Int!$orderByDirection:OrderDirection!$orderByField:DiscussionOrderField!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){discussions(first: $first, after: $after, orderBy: { field: $orderByField, direction: $orderByDirection }){nodes{number,title,createdAt,updatedAt,author{login},category{name},url},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}"
- qWithCategoryAndOrder := "query($after:String$categoryId:ID!$first:Int!$orderByDirection:OrderDirection!$orderByField:DiscussionOrderField!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){discussions(first: $first, after: $after, categoryId: $categoryId, orderBy: { field: $orderByField, direction: $orderByDirection }){nodes{number,title,createdAt,updatedAt,author{login},category{name},url},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}"
-
- for _, tc := range tests {
- t.Run(tc.name, func(t *testing.T) {
- var httpClient *http.Client
-
- switch tc.name {
- case "list all discussions without category filter":
- matcher := githubv4mock.NewQueryMatcher(qBasicNoOrder, varsListAll, mockResponseListAll)
- httpClient = githubv4mock.NewMockedHTTPClient(matcher)
- case "filter by category ID":
- matcher := githubv4mock.NewQueryMatcher(qWithCategoryNoOrder, varsDiscussionsFiltered, mockResponseListGeneral)
- httpClient = githubv4mock.NewMockedHTTPClient(matcher)
- case "order by created at ascending":
- matcher := githubv4mock.NewQueryMatcher(qBasicWithOrder, varsOrderByCreatedAsc, mockResponseOrderedCreatedAsc)
- httpClient = githubv4mock.NewMockedHTTPClient(matcher)
- case "order by updated at descending":
- matcher := githubv4mock.NewQueryMatcher(qBasicWithOrder, varsOrderByUpdatedDesc, mockResponseOrderedUpdatedDesc)
- httpClient = githubv4mock.NewMockedHTTPClient(matcher)
- case "filter by category with order":
- matcher := githubv4mock.NewQueryMatcher(qWithCategoryAndOrder, varsCategoryWithOrder, mockResponseGeneralOrderedDesc)
- httpClient = githubv4mock.NewMockedHTTPClient(matcher)
- case "order by without direction (should not use ordering)":
- matcher := githubv4mock.NewQueryMatcher(qBasicNoOrder, varsListAll, mockResponseListAll)
- httpClient = githubv4mock.NewMockedHTTPClient(matcher)
- case "direction without order by (should not use ordering)":
- matcher := githubv4mock.NewQueryMatcher(qBasicNoOrder, varsListAll, mockResponseListAll)
- httpClient = githubv4mock.NewMockedHTTPClient(matcher)
- case "repository not found error":
- matcher := githubv4mock.NewQueryMatcher(qBasicNoOrder, varsRepoNotFound, mockErrorRepoNotFound)
- httpClient = githubv4mock.NewMockedHTTPClient(matcher)
- case "list org-level discussions (no repo provided)":
- matcher := githubv4mock.NewQueryMatcher(qBasicNoOrder, varsOrgLevel, mockResponseOrgLevel)
- httpClient = githubv4mock.NewMockedHTTPClient(matcher)
- }
-
- gqlClient := githubv4.NewClient(httpClient)
- _, handler := ListDiscussions(stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper)
-
- req := createMCPRequest(tc.reqParams)
- res, err := handler(context.Background(), req)
- text := getTextResult(t, res).Text
-
- if tc.expectError {
- require.True(t, res.IsError)
- assert.Contains(t, text, tc.errContains)
- return
- }
- require.NoError(t, err)
-
- // Parse the structured response with pagination info
- var response struct {
- Discussions []*github.Discussion `json:"discussions"`
- PageInfo struct {
- HasNextPage bool `json:"hasNextPage"`
- HasPreviousPage bool `json:"hasPreviousPage"`
- StartCursor string `json:"startCursor"`
- EndCursor string `json:"endCursor"`
- } `json:"pageInfo"`
- TotalCount int `json:"totalCount"`
- }
- err = json.Unmarshal([]byte(text), &response)
- require.NoError(t, err)
-
- assert.Len(t, response.Discussions, tc.expectedCount, "Expected %d discussions, got %d", tc.expectedCount, len(response.Discussions))
-
- // Verify order if verifyOrder function is provided
- if tc.verifyOrder != nil {
- tc.verifyOrder(t, response.Discussions)
- }
-
- // Verify that all returned discussions have a category if filtered
- if _, hasCategory := tc.reqParams["category"]; hasCategory {
- for _, discussion := range response.Discussions {
- require.NotNil(t, discussion.DiscussionCategory, "Discussion should have category")
- assert.NotEmpty(t, *discussion.DiscussionCategory.Name, "Discussion should have category name")
- }
- }
- })
- }
-}
-
-func Test_GetDiscussion(t *testing.T) {
- // Verify tool definition and schema
- toolDef, _ := GetDiscussion(nil, translations.NullTranslationHelper)
- assert.Equal(t, "get_discussion", toolDef.Name)
- assert.NotEmpty(t, toolDef.Description)
- assert.Contains(t, toolDef.InputSchema.Properties, "owner")
- assert.Contains(t, toolDef.InputSchema.Properties, "repo")
- assert.Contains(t, toolDef.InputSchema.Properties, "discussionNumber")
- assert.ElementsMatch(t, toolDef.InputSchema.Required, []string{"owner", "repo", "discussionNumber"})
-
- // Use exact string query that matches implementation output
- qGetDiscussion := "query($discussionNumber:Int!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){discussion(number: $discussionNumber){number,title,body,createdAt,url,category{name}}}}"
-
- vars := map[string]interface{}{
- "owner": "owner",
- "repo": "repo",
- "discussionNumber": float64(1),
- }
- tests := []struct {
- name string
- response githubv4mock.GQLResponse
- expectError bool
- expected *github.Discussion
- errContains string
- }{
- {
- name: "successful retrieval",
- response: githubv4mock.DataResponse(map[string]any{
- "repository": map[string]any{"discussion": map[string]any{
- "number": 1,
- "title": "Test Discussion Title",
- "body": "This is a test discussion",
- "url": "https://github.com/owner/repo/discussions/1",
- "createdAt": "2025-04-25T12:00:00Z",
- "category": map[string]any{"name": "General"},
- }},
- }),
- expectError: false,
- expected: &github.Discussion{
- HTMLURL: github.Ptr("https://github.com/owner/repo/discussions/1"),
- Number: github.Ptr(1),
- Title: github.Ptr("Test Discussion Title"),
- Body: github.Ptr("This is a test discussion"),
- CreatedAt: &github.Timestamp{Time: time.Date(2025, 4, 25, 12, 0, 0, 0, time.UTC)},
- DiscussionCategory: &github.DiscussionCategory{
- Name: github.Ptr("General"),
- },
- },
- },
- {
- name: "discussion not found",
- response: githubv4mock.ErrorResponse("discussion not found"),
- expectError: true,
- errContains: "discussion not found",
- },
- }
- for _, tc := range tests {
- t.Run(tc.name, func(t *testing.T) {
- matcher := githubv4mock.NewQueryMatcher(qGetDiscussion, vars, tc.response)
- httpClient := githubv4mock.NewMockedHTTPClient(matcher)
- gqlClient := githubv4.NewClient(httpClient)
- _, handler := GetDiscussion(stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper)
-
- req := createMCPRequest(map[string]interface{}{"owner": "owner", "repo": "repo", "discussionNumber": int32(1)})
- res, err := handler(context.Background(), req)
- text := getTextResult(t, res).Text
-
- if tc.expectError {
- require.True(t, res.IsError)
- assert.Contains(t, text, tc.errContains)
- return
- }
-
- require.NoError(t, err)
- var out github.Discussion
- require.NoError(t, json.Unmarshal([]byte(text), &out))
- assert.Equal(t, *tc.expected.HTMLURL, *out.HTMLURL)
- assert.Equal(t, *tc.expected.Number, *out.Number)
- assert.Equal(t, *tc.expected.Title, *out.Title)
- assert.Equal(t, *tc.expected.Body, *out.Body)
- // Check category label
- assert.Equal(t, *tc.expected.DiscussionCategory.Name, *out.DiscussionCategory.Name)
- })
- }
-}
-
-func Test_GetDiscussionComments(t *testing.T) {
- // Verify tool definition and schema
- toolDef, _ := GetDiscussionComments(nil, translations.NullTranslationHelper)
- assert.Equal(t, "get_discussion_comments", toolDef.Name)
- assert.NotEmpty(t, toolDef.Description)
- assert.Contains(t, toolDef.InputSchema.Properties, "owner")
- assert.Contains(t, toolDef.InputSchema.Properties, "repo")
- assert.Contains(t, toolDef.InputSchema.Properties, "discussionNumber")
- assert.ElementsMatch(t, toolDef.InputSchema.Required, []string{"owner", "repo", "discussionNumber"})
-
- // Use exact string query that matches implementation output
- qGetComments := "query($after:String$discussionNumber:Int!$first:Int!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){discussion(number: $discussionNumber){comments(first: $first, after: $after){nodes{body},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}}"
-
- // Variables matching what GraphQL receives after JSON marshaling/unmarshaling
- vars := map[string]interface{}{
- "owner": "owner",
- "repo": "repo",
- "discussionNumber": float64(1),
- "first": float64(30),
- "after": (*string)(nil),
- }
-
- mockResponse := githubv4mock.DataResponse(map[string]any{
- "repository": map[string]any{
- "discussion": map[string]any{
- "comments": map[string]any{
- "nodes": []map[string]any{
- {"body": "This is the first comment"},
- {"body": "This is the second comment"},
- },
- "pageInfo": map[string]any{
- "hasNextPage": false,
- "hasPreviousPage": false,
- "startCursor": "",
- "endCursor": "",
- },
- "totalCount": 2,
- },
- },
- },
- })
- matcher := githubv4mock.NewQueryMatcher(qGetComments, vars, mockResponse)
- httpClient := githubv4mock.NewMockedHTTPClient(matcher)
- gqlClient := githubv4.NewClient(httpClient)
- _, handler := GetDiscussionComments(stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper)
-
- request := createMCPRequest(map[string]interface{}{
- "owner": "owner",
- "repo": "repo",
- "discussionNumber": int32(1),
- })
-
- result, err := handler(context.Background(), request)
- require.NoError(t, err)
-
- textContent := getTextResult(t, result)
-
- // (Lines removed)
-
- var response struct {
- Comments []*github.IssueComment `json:"comments"`
- PageInfo struct {
- HasNextPage bool `json:"hasNextPage"`
- HasPreviousPage bool `json:"hasPreviousPage"`
- StartCursor string `json:"startCursor"`
- EndCursor string `json:"endCursor"`
- } `json:"pageInfo"`
- TotalCount int `json:"totalCount"`
- }
- err = json.Unmarshal([]byte(textContent.Text), &response)
- require.NoError(t, err)
- assert.Len(t, response.Comments, 2)
- expectedBodies := []string{"This is the first comment", "This is the second comment"}
- for i, comment := range response.Comments {
- assert.Equal(t, expectedBodies[i], *comment.Body)
- }
-}
-
-func Test_ListDiscussionCategories(t *testing.T) {
- mockClient := githubv4.NewClient(nil)
- toolDef, _ := ListDiscussionCategories(stubGetGQLClientFn(mockClient), translations.NullTranslationHelper)
- assert.Equal(t, "list_discussion_categories", toolDef.Name)
- assert.NotEmpty(t, toolDef.Description)
- assert.Contains(t, toolDef.Description, "or organisation")
- assert.Contains(t, toolDef.InputSchema.Properties, "owner")
- assert.Contains(t, toolDef.InputSchema.Properties, "repo")
- assert.ElementsMatch(t, toolDef.InputSchema.Required, []string{"owner"})
-
- // Use exact string query that matches implementation output
- qListCategories := "query($first:Int!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){discussionCategories(first: $first){nodes{id,name},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}"
-
- // Variables for repository-level categories
- varsRepo := map[string]interface{}{
- "owner": "owner",
- "repo": "repo",
- "first": float64(25),
- }
-
- // Variables for organization-level categories (using .github repo)
- varsOrg := map[string]interface{}{
- "owner": "owner",
- "repo": ".github",
- "first": float64(25),
- }
-
- mockRespRepo := githubv4mock.DataResponse(map[string]any{
- "repository": map[string]any{
- "discussionCategories": map[string]any{
- "nodes": []map[string]any{
- {"id": "123", "name": "CategoryOne"},
- {"id": "456", "name": "CategoryTwo"},
- },
- "pageInfo": map[string]any{
- "hasNextPage": false,
- "hasPreviousPage": false,
- "startCursor": "",
- "endCursor": "",
- },
- "totalCount": 2,
- },
- },
- })
-
- mockRespOrg := githubv4mock.DataResponse(map[string]any{
- "repository": map[string]any{
- "discussionCategories": map[string]any{
- "nodes": []map[string]any{
- {"id": "789", "name": "Announcements"},
- {"id": "101", "name": "General"},
- {"id": "112", "name": "Ideas"},
- },
- "pageInfo": map[string]any{
- "hasNextPage": false,
- "hasPreviousPage": false,
- "startCursor": "",
- "endCursor": "",
- },
- "totalCount": 3,
- },
- },
- })
-
- tests := []struct {
- name string
- reqParams map[string]interface{}
- vars map[string]interface{}
- mockResponse githubv4mock.GQLResponse
- expectError bool
- expectedCount int
- expectedCategories []map[string]string
- }{
- {
- name: "list repository-level discussion categories",
- reqParams: map[string]interface{}{
- "owner": "owner",
- "repo": "repo",
- },
- vars: varsRepo,
- mockResponse: mockRespRepo,
- expectError: false,
- expectedCount: 2,
- expectedCategories: []map[string]string{
- {"id": "123", "name": "CategoryOne"},
- {"id": "456", "name": "CategoryTwo"},
- },
- },
- {
- name: "list org-level discussion categories (no repo provided)",
- reqParams: map[string]interface{}{
- "owner": "owner",
- // repo is not provided, it will default to ".github"
- },
- vars: varsOrg,
- mockResponse: mockRespOrg,
- expectError: false,
- expectedCount: 3,
- expectedCategories: []map[string]string{
- {"id": "789", "name": "Announcements"},
- {"id": "101", "name": "General"},
- {"id": "112", "name": "Ideas"},
- },
- },
- }
-
- for _, tc := range tests {
- t.Run(tc.name, func(t *testing.T) {
- matcher := githubv4mock.NewQueryMatcher(qListCategories, tc.vars, tc.mockResponse)
- httpClient := githubv4mock.NewMockedHTTPClient(matcher)
- gqlClient := githubv4.NewClient(httpClient)
-
- _, handler := ListDiscussionCategories(stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper)
-
- req := createMCPRequest(tc.reqParams)
- res, err := handler(context.Background(), req)
- text := getTextResult(t, res).Text
-
- if tc.expectError {
- require.True(t, res.IsError)
- return
- }
- require.NoError(t, err)
-
- var response struct {
- Categories []map[string]string `json:"categories"`
- PageInfo struct {
- HasNextPage bool `json:"hasNextPage"`
- HasPreviousPage bool `json:"hasPreviousPage"`
- StartCursor string `json:"startCursor"`
- EndCursor string `json:"endCursor"`
- } `json:"pageInfo"`
- TotalCount int `json:"totalCount"`
- }
- require.NoError(t, json.Unmarshal([]byte(text), &response))
- assert.Equal(t, tc.expectedCategories, response.Categories)
- })
- }
-}
diff --git a/pkg/github/rules.go b/pkg/github/rules.go
new file mode 100644
index 000000000..685d1b4c9
--- /dev/null
+++ b/pkg/github/rules.go
@@ -0,0 +1,1108 @@
+package github
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strconv"
+ "strings"
+
+ ghErrors "github.com/github/github-mcp-server/pkg/errors"
+ "github.com/github/github-mcp-server/pkg/translations"
+ "github.com/google/go-github/v73/github"
+ "github.com/mark3labs/mcp-go/mcp"
+ "github.com/mark3labs/mcp-go/server"
+)
+
+// GetRepositoryRuleset creates a tool to get a specific repository ruleset.
+func GetRepositoryRuleset(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
+ return mcp.NewTool("get_repository_ruleset",
+ mcp.WithDescription(t("TOOL_GET_REPOSITORY_RULESET_DESCRIPTION", "Get details of a specific repository ruleset")),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ Title: t("TOOL_GET_REPOSITORY_RULESET_USER_TITLE", "Get repository ruleset"),
+ ReadOnlyHint: ToBoolPtr(true),
+ }),
+ mcp.WithString("owner",
+ mcp.Required(),
+ mcp.Description("Repository owner"),
+ ),
+ mcp.WithString("repo",
+ mcp.Required(),
+ mcp.Description("Repository name"),
+ ),
+ mcp.WithNumber("rulesetId",
+ mcp.Required(),
+ mcp.Description("Ruleset ID"),
+ ),
+ mcp.WithBoolean("includesParents",
+ mcp.Description("Include rulesets configured at higher levels that also apply"),
+ ),
+ ),
+ func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ owner, err := RequiredParam[string](request, "owner")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ repo, err := RequiredParam[string](request, "repo")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ rulesetID, err := RequiredInt(request, "rulesetId")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ includesParents, err := OptionalParam[bool](request, "includesParents")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+
+ client, err := getClient(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get GitHub client: %w", err)
+ }
+
+ ruleset, resp, err := client.Repositories.GetRuleset(ctx, owner, repo, int64(rulesetID), includesParents)
+ if err != nil {
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ "failed to get repository ruleset",
+ resp,
+ err,
+ ), nil
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ return MarshalledTextResult(ruleset), nil
+ }
+}
+
+// ListRepositoryRulesets creates a tool to list all repository rulesets.
+func ListRepositoryRulesets(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
+ return mcp.NewTool("list_repository_rulesets",
+ mcp.WithDescription(t("TOOL_LIST_REPOSITORY_RULESETS_DESCRIPTION", "List all repository rulesets")),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ Title: t("TOOL_LIST_REPOSITORY_RULESETS_USER_TITLE", "List repository rulesets"),
+ ReadOnlyHint: ToBoolPtr(true),
+ }),
+ mcp.WithString("owner",
+ mcp.Required(),
+ mcp.Description("Repository owner"),
+ ),
+ mcp.WithString("repo",
+ mcp.Required(),
+ mcp.Description("Repository name"),
+ ),
+ mcp.WithBoolean("includesParents",
+ mcp.Description("Include rulesets configured at higher levels that also apply"),
+ ),
+ ),
+ func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ owner, err := RequiredParam[string](request, "owner")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ repo, err := RequiredParam[string](request, "repo")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ includesParents, err := OptionalParam[bool](request, "includesParents")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+
+ client, err := getClient(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get GitHub client: %w", err)
+ }
+
+ opts := &github.RepositoryListRulesetsOptions{
+ IncludesParents: &includesParents,
+ }
+
+ rulesets, resp, err := client.Repositories.GetAllRulesets(ctx, owner, repo, opts)
+ if err != nil {
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ "failed to list repository rulesets",
+ resp,
+ err,
+ ), nil
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ return MarshalledTextResult(rulesets), nil
+ }
+}
+
+// GetRepositoryRulesForBranch creates a tool to get all repository rules that apply to a specific branch.
+func GetRepositoryRulesForBranch(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
+ return mcp.NewTool("get_repository_rules_for_branch",
+ mcp.WithDescription(t("TOOL_GET_REPOSITORY_RULES_FOR_BRANCH_DESCRIPTION", "Get all repository rules that apply to a specific branch")),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ Title: t("TOOL_GET_REPOSITORY_RULES_FOR_BRANCH_USER_TITLE", "Get rules for branch"),
+ ReadOnlyHint: ToBoolPtr(true),
+ }),
+ mcp.WithString("owner",
+ mcp.Required(),
+ mcp.Description("Repository owner"),
+ ),
+ mcp.WithString("repo",
+ mcp.Required(),
+ mcp.Description("Repository name"),
+ ),
+ mcp.WithString("branch",
+ mcp.Required(),
+ mcp.Description("Branch name"),
+ ),
+ WithPagination(),
+ ),
+ func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ owner, err := RequiredParam[string](request, "owner")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ repo, err := RequiredParam[string](request, "repo")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ branch, err := RequiredParam[string](request, "branch")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ pagination, err := OptionalPaginationParams(request)
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+
+ client, err := getClient(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get GitHub client: %w", err)
+ }
+
+ opts := &github.ListOptions{
+ Page: pagination.Page,
+ PerPage: pagination.PerPage,
+ }
+
+ branchRules, resp, err := client.Repositories.GetRulesForBranch(ctx, owner, repo, branch, opts)
+ if err != nil {
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ "failed to get repository rules for branch",
+ resp,
+ err,
+ ), nil
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ return MarshalledTextResult(branchRules), nil
+ }
+}
+
+// GetOrganizationRepositoryRuleset creates a tool to get a specific organization repository ruleset.
+func GetOrganizationRepositoryRuleset(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
+ return mcp.NewTool("get_organization_repository_ruleset",
+ mcp.WithDescription(t("TOOL_GET_ORGANIZATION_REPOSITORY_RULESET_DESCRIPTION", "Get details of a specific organization repository ruleset")),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ Title: t("TOOL_GET_ORGANIZATION_REPOSITORY_RULESET_USER_TITLE", "Get organization repository ruleset"),
+ ReadOnlyHint: ToBoolPtr(true),
+ }),
+ mcp.WithString("org",
+ mcp.Required(),
+ mcp.Description("Organization name"),
+ ),
+ mcp.WithNumber("rulesetId",
+ mcp.Required(),
+ mcp.Description("Ruleset ID"),
+ ),
+ ),
+ func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ org, err := RequiredParam[string](request, "org")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ rulesetID, err := RequiredInt(request, "rulesetId")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+
+ client, err := getClient(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get GitHub client: %w", err)
+ }
+
+ ruleset, resp, err := client.Organizations.GetRepositoryRuleset(ctx, org, int64(rulesetID))
+ if err != nil {
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ "failed to get organization repository ruleset",
+ resp,
+ err,
+ ), nil
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ return MarshalledTextResult(ruleset), nil
+ }
+}
+
+// ListOrganizationRepositoryRulesets creates a tool to list all organization repository rulesets.
+func ListOrganizationRepositoryRulesets(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
+ return mcp.NewTool("list_organization_repository_rulesets",
+ mcp.WithDescription(t("TOOL_LIST_ORGANIZATION_REPOSITORY_RULESETS_DESCRIPTION", "List all organization repository rulesets")),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ Title: t("TOOL_LIST_ORGANIZATION_REPOSITORY_RULESETS_USER_TITLE", "List organization repository rulesets"),
+ ReadOnlyHint: ToBoolPtr(true),
+ }),
+ mcp.WithString("org",
+ mcp.Required(),
+ mcp.Description("Organization name"),
+ ),
+ WithPagination(),
+ ),
+ func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ org, err := RequiredParam[string](request, "org")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ pagination, err := OptionalPaginationParams(request)
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+
+ client, err := getClient(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get GitHub client: %w", err)
+ }
+
+ opts := &github.ListOptions{
+ Page: pagination.Page,
+ PerPage: pagination.PerPage,
+ }
+
+ rulesets, resp, err := client.Organizations.GetAllRepositoryRulesets(ctx, org, opts)
+ if err != nil {
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ "failed to list organization repository rulesets",
+ resp,
+ err,
+ ), nil
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ return MarshalledTextResult(rulesets), nil
+ }
+}
+
+// RuleSuite represents a rule suite from GitHub API
+type RuleSuite struct {
+ ID *int64 `json:"id,omitempty"`
+ ActorID *int64 `json:"actor_id,omitempty"`
+ ActorName *string `json:"actor_name,omitempty"`
+ BeforeSHA *string `json:"before_sha,omitempty"`
+ AfterSHA *string `json:"after_sha,omitempty"`
+ Ref *string `json:"ref,omitempty"`
+ RepositoryID *int64 `json:"repository_id,omitempty"`
+ RepositoryName *string `json:"repository_name,omitempty"`
+ PushedAt *string `json:"pushed_at,omitempty"`
+ Result *string `json:"result,omitempty"`
+ EvaluationResult *string `json:"evaluation_result,omitempty"`
+ RuleEvaluations []RuleEvaluation `json:"rule_evaluations,omitempty"`
+}
+
+// RuleEvaluation represents a rule evaluation within a rule suite
+type RuleEvaluation struct {
+ RuleSource *RuleSource `json:"rule_source,omitempty"`
+ Enforcement *string `json:"enforcement,omitempty"`
+ Result *string `json:"result,omitempty"`
+ RuleType *string `json:"rule_type,omitempty"`
+ Details *string `json:"details,omitempty"`
+}
+
+// RuleSource represents the source of a rule
+type RuleSource struct {
+ Type *string `json:"type,omitempty"`
+ ID *int64 `json:"id,omitempty"`
+ Name *string `json:"name,omitempty"`
+}
+
+// RuleSuitesResponse represents the response from list rule suites API
+type RuleSuitesResponse struct {
+ RuleSuites []*RuleSuite `json:"rule_suites,omitempty"`
+}
+
+// ListRepositoryRuleSuites creates a tool to list rule suites for a repository.
+func ListRepositoryRuleSuites(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
+ return mcp.NewTool("list_repository_rule_suites",
+ mcp.WithDescription(t("TOOL_LIST_REPOSITORY_RULE_SUITES_DESCRIPTION", "List rule suites for a repository")),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ Title: t("TOOL_LIST_REPOSITORY_RULE_SUITES_USER_TITLE", "List repository rule suites"),
+ ReadOnlyHint: ToBoolPtr(true),
+ }),
+ mcp.WithString("owner",
+ mcp.Required(),
+ mcp.Description("Repository owner"),
+ ),
+ mcp.WithString("repo",
+ mcp.Required(),
+ mcp.Description("Repository name"),
+ ),
+ mcp.WithString("ref",
+ mcp.Description("The name of the ref (branch, tag, etc.) to filter rule suites by"),
+ ),
+ mcp.WithString("timePeriod",
+ mcp.Description("The time period to filter by. Options: hour, day, week, month"),
+ ),
+ mcp.WithString("actorName",
+ mcp.Description("The handle for the GitHub user account to filter on"),
+ ),
+ mcp.WithString("ruleSuiteResult",
+ mcp.Description("The rule suite result to filter by. Options: pass, fail, bypass"),
+ ),
+ WithPagination(),
+ ),
+ func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ owner, err := RequiredParam[string](request, "owner")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ repo, err := RequiredParam[string](request, "repo")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+
+ // Optional parameters
+ ref, err := OptionalParam[string](request, "ref")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ timePeriod, err := OptionalParam[string](request, "timePeriod")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ actorName, err := OptionalParam[string](request, "actorName")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ ruleSuiteResult, err := OptionalParam[string](request, "ruleSuiteResult")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ pagination, err := OptionalPaginationParams(request)
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+
+ client, err := getClient(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get GitHub client: %w", err)
+ }
+
+ // Build URL with query parameters
+ u := fmt.Sprintf("https://api.github.com/repos/%s/%s/rulesets/rule-suites", url.PathEscape(owner), url.PathEscape(repo))
+ query := url.Values{}
+
+ if ref != "" {
+ query.Add("ref", ref)
+ }
+ if timePeriod != "" {
+ query.Add("time_period", timePeriod)
+ }
+ if actorName != "" {
+ query.Add("actor_name", actorName)
+ }
+ if ruleSuiteResult != "" {
+ query.Add("rule_suite_result", ruleSuiteResult)
+ }
+ if pagination.Page > 0 {
+ query.Add("page", strconv.Itoa(pagination.Page))
+ }
+ if pagination.PerPage > 0 {
+ query.Add("per_page", strconv.Itoa(pagination.PerPage))
+ }
+
+ if len(query) > 0 {
+ u += "?" + query.Encode()
+ }
+
+ req, err := http.NewRequestWithContext(ctx, "GET", u, nil)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create request: %w", err)
+ }
+
+ req.Header.Set("Accept", "application/vnd.github+json")
+ req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
+
+ httpClient := client.Client()
+ resp, err := httpClient.Do(req)
+ if err != nil {
+ var ghResp *github.Response
+ if resp != nil {
+ ghResp = &github.Response{Response: resp}
+ }
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ "failed to list repository rule suites",
+ ghResp,
+ err,
+ ), nil
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read response body: %w", err)
+ }
+
+ if resp.StatusCode != http.StatusOK {
+ var ghResp *github.Response
+ if resp != nil {
+ ghResp = &github.Response{Response: resp}
+ }
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ "failed to list repository rule suites",
+ ghResp,
+ fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body)),
+ ), nil
+ }
+
+ var ruleSuites RuleSuitesResponse
+ if err := json.Unmarshal(body, &ruleSuites); err != nil {
+ return nil, fmt.Errorf("failed to unmarshal response: %w", err)
+ }
+
+ return MarshalledTextResult(ruleSuites.RuleSuites), nil
+ }
+}
+
+// GetRepositoryRuleSuite creates a tool to get details of a specific repository rule suite.
+func GetRepositoryRuleSuite(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
+ return mcp.NewTool("get_repository_rule_suite",
+ mcp.WithDescription(t("TOOL_GET_REPOSITORY_RULE_SUITE_DESCRIPTION", "Get details of a specific repository rule suite")),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ Title: t("TOOL_GET_REPOSITORY_RULE_SUITE_USER_TITLE", "Get repository rule suite"),
+ ReadOnlyHint: ToBoolPtr(true),
+ }),
+ mcp.WithString("owner",
+ mcp.Required(),
+ mcp.Description("Repository owner"),
+ ),
+ mcp.WithString("repo",
+ mcp.Required(),
+ mcp.Description("Repository name"),
+ ),
+ mcp.WithNumber("ruleSuiteId",
+ mcp.Required(),
+ mcp.Description("Rule suite ID"),
+ ),
+ ),
+ func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ owner, err := RequiredParam[string](request, "owner")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ repo, err := RequiredParam[string](request, "repo")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ ruleSuiteID, err := RequiredInt(request, "ruleSuiteId")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+
+ client, err := getClient(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get GitHub client: %w", err)
+ }
+
+ u := fmt.Sprintf("https://api.github.com/repos/%s/%s/rulesets/rule-suites/%d",
+ url.PathEscape(owner), url.PathEscape(repo), ruleSuiteID)
+
+ req, err := http.NewRequestWithContext(ctx, "GET", u, nil)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create request: %w", err)
+ }
+
+ req.Header.Set("Accept", "application/vnd.github+json")
+ req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
+
+ httpClient := client.Client()
+ resp, err := httpClient.Do(req)
+ if err != nil {
+ var ghResp *github.Response
+ if resp != nil {
+ ghResp = &github.Response{Response: resp}
+ }
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ "failed to get repository rule suite",
+ ghResp,
+ err,
+ ), nil
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read response body: %w", err)
+ }
+
+ if resp.StatusCode != http.StatusOK {
+ var ghResp *github.Response
+ if resp != nil {
+ ghResp = &github.Response{Response: resp}
+ }
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ "failed to get repository rule suite",
+ ghResp,
+ fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body)),
+ ), nil
+ }
+
+ var ruleSuite RuleSuite
+ if err := json.Unmarshal(body, &ruleSuite); err != nil {
+ return nil, fmt.Errorf("failed to unmarshal response: %w", err)
+ }
+
+ return MarshalledTextResult(ruleSuite), nil
+ }
+}
+
+// CreateRepositoryRuleset creates a tool to create a new repository ruleset.
+func CreateRepositoryRuleset(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
+ return mcp.NewTool("create_repository_ruleset",
+ mcp.WithDescription(t("TOOL_CREATE_REPOSITORY_RULESET_DESCRIPTION", "Create a new repository ruleset")),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ Title: t("TOOL_CREATE_REPOSITORY_RULESET_USER_TITLE", "Create repository ruleset"),
+ ReadOnlyHint: ToBoolPtr(false),
+ }),
+ mcp.WithString("owner",
+ mcp.Required(),
+ mcp.Description("Repository owner"),
+ ),
+ mcp.WithString("repo",
+ mcp.Required(),
+ mcp.Description("Repository name"),
+ ),
+ mcp.WithString("name",
+ mcp.Required(),
+ mcp.Description("The name of the ruleset"),
+ ),
+ mcp.WithString("enforcement",
+ mcp.Required(),
+ mcp.Description("The enforcement level of the ruleset. Can be 'disabled', 'active', or 'evaluate'"),
+ ),
+ mcp.WithString("target",
+ mcp.Description("The target of the ruleset. Defaults to 'branch'. Can be one of: 'branch', 'tag', or 'push'"),
+ ),
+ mcp.WithArray("rules",
+ mcp.Required(),
+ mcp.Description("An array of rules within the ruleset"),
+ mcp.Items(
+ map[string]any{
+ "type": "object",
+ },
+ ),
+ ),
+ mcp.WithObject("conditions",
+ mcp.Description("Conditions for when this ruleset applies"),
+ ),
+ mcp.WithArray("bypass_actors",
+ mcp.Description("The actors that can bypass the rules in this ruleset"),
+ mcp.Items(
+ map[string]any{
+ "type": "object",
+ },
+ ),
+ ),
+ ),
+ func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ owner, err := RequiredParam[string](request, "owner")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ repo, err := RequiredParam[string](request, "repo")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ name, err := RequiredParam[string](request, "name")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ enforcement, err := RequiredParam[string](request, "enforcement")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+
+ // Validate enforcement value
+ if enforcement != "disabled" && enforcement != "active" && enforcement != "evaluate" {
+ return mcp.NewToolResultError("enforcement must be one of: 'disabled', 'active', 'evaluate'"), nil
+ }
+
+ // Parse rules parameter - required array
+ rulesObj, ok := request.GetArguments()["rules"].([]interface{})
+ if !ok {
+ return mcp.NewToolResultError("rules parameter must be an array of rule objects"), nil
+ }
+
+ // Optional parameters
+ target, err := OptionalParam[string](request, "target")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ if target == "" {
+ target = "branch" // Default value
+ }
+
+ var conditionsObj map[string]interface{}
+ if conditionsVal, exists := request.GetArguments()["conditions"]; exists {
+ if conditionsMap, ok := conditionsVal.(map[string]interface{}); ok {
+ conditionsObj = conditionsMap
+ } else {
+ return mcp.NewToolResultError("conditions parameter must be an object"), nil
+ }
+ }
+
+ var bypassActorsObj []interface{}
+ if bypassVal, exists := request.GetArguments()["bypass_actors"]; exists {
+ if bypassArr, ok := bypassVal.([]interface{}); ok {
+ bypassActorsObj = bypassArr
+ } else {
+ return mcp.NewToolResultError("bypass_actors parameter must be an array of objects"), nil
+ }
+ }
+
+ client, err := getClient(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get GitHub client: %w", err)
+ }
+
+ // Build ruleset creation request
+ rulesetReq := map[string]any{
+ "name": name,
+ "enforcement": enforcement,
+ "target": target,
+ "rules": rulesObj,
+ }
+
+ if conditionsObj != nil {
+ rulesetReq["conditions"] = conditionsObj
+ }
+ if bypassActorsObj != nil {
+ rulesetReq["bypass_actors"] = bypassActorsObj
+ }
+
+ // Convert to JSON for the API request
+ jsonData, err := json.Marshal(rulesetReq)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal ruleset request: %w", err)
+ }
+
+ // Make the API request
+ u := fmt.Sprintf("https://api.github.com/repos/%s/%s/rulesets", url.PathEscape(owner), url.PathEscape(repo))
+
+ // Create a new request with the JSON body
+ req, err := http.NewRequestWithContext(ctx, "POST", u, strings.NewReader(string(jsonData)))
+ if err != nil {
+ return nil, fmt.Errorf("failed to create request: %w", err)
+ }
+
+ req.Header.Set("Accept", "application/vnd.github+json")
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
+
+ // Use the GitHub client's underlying HTTP client to make the request
+ httpClient := client.Client()
+
+ resp, err := httpClient.Do(req)
+ if err != nil {
+ var ghResp *github.Response
+ if resp != nil {
+ ghResp = &github.Response{Response: resp}
+ }
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ "failed to create repository ruleset",
+ ghResp,
+ err,
+ ), nil
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read response body: %w", err)
+ }
+
+ if resp.StatusCode != http.StatusCreated {
+ var ghResp *github.Response
+ if resp != nil {
+ ghResp = &github.Response{Response: resp}
+ }
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ "failed to create repository ruleset",
+ ghResp,
+ fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body)),
+ ), nil
+ }
+
+ var createdRuleset map[string]any
+ if err := json.Unmarshal(body, &createdRuleset); err != nil {
+ return nil, fmt.Errorf("failed to unmarshal response: %w", err)
+ }
+
+ return MarshalledTextResult(createdRuleset), nil
+ }
+}
+
+// CreateOrganizationRepositoryRuleset creates a tool to create a new organization repository ruleset.
+func CreateOrganizationRepositoryRuleset(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
+ return mcp.NewTool("create_organization_repository_ruleset",
+ mcp.WithDescription(t("TOOL_CREATE_ORGANIZATION_REPOSITORY_RULESET_DESCRIPTION", "Create a new organization repository ruleset")),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ Title: t("TOOL_CREATE_ORGANIZATION_REPOSITORY_RULESET_USER_TITLE", "Create organization repository ruleset"),
+ ReadOnlyHint: ToBoolPtr(false),
+ }),
+ mcp.WithString("org",
+ mcp.Required(),
+ mcp.Description("Organization name"),
+ ),
+ mcp.WithString("name",
+ mcp.Required(),
+ mcp.Description("The name of the ruleset"),
+ ),
+ mcp.WithString("enforcement",
+ mcp.Required(),
+ mcp.Description("The enforcement level of the ruleset. Can be 'disabled', 'active', or 'evaluate'"),
+ ),
+ mcp.WithString("target",
+ mcp.Description("The target of the ruleset. Defaults to 'branch'. Can be one of: 'branch', 'tag', or 'push'"),
+ ),
+ mcp.WithArray("rules",
+ mcp.Required(),
+ mcp.Description("An array of rules within the ruleset"),
+ mcp.Items(
+ map[string]any{
+ "type": "object",
+ },
+ ),
+ ),
+ mcp.WithObject("conditions",
+ mcp.Description("Conditions for when this ruleset applies"),
+ ),
+ mcp.WithArray("bypass_actors",
+ mcp.Description("The actors that can bypass the rules in this ruleset"),
+ mcp.Items(
+ map[string]any{
+ "type": "object",
+ },
+ ),
+ ),
+ ),
+ func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ org, err := RequiredParam[string](request, "org")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ name, err := RequiredParam[string](request, "name")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ enforcement, err := RequiredParam[string](request, "enforcement")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+
+ // Validate enforcement value
+ if enforcement != "disabled" && enforcement != "active" && enforcement != "evaluate" {
+ return mcp.NewToolResultError("enforcement must be one of: 'disabled', 'active', 'evaluate'"), nil
+ }
+
+ // Parse rules parameter - required array
+ rulesObj, ok := request.GetArguments()["rules"].([]interface{})
+ if !ok {
+ return mcp.NewToolResultError("rules parameter must be an array of rule objects"), nil
+ }
+
+ // Optional parameters
+ target, err := OptionalParam[string](request, "target")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ if target == "" {
+ target = "branch" // Default value
+ }
+
+ var conditionsObj map[string]interface{}
+ if conditionsVal, exists := request.GetArguments()["conditions"]; exists {
+ if conditionsMap, ok := conditionsVal.(map[string]interface{}); ok {
+ conditionsObj = conditionsMap
+ } else {
+ return mcp.NewToolResultError("conditions parameter must be an object"), nil
+ }
+ }
+
+ var bypassActorsObj []interface{}
+ if bypassVal, exists := request.GetArguments()["bypass_actors"]; exists {
+ if bypassArr, ok := bypassVal.([]interface{}); ok {
+ bypassActorsObj = bypassArr
+ } else {
+ return mcp.NewToolResultError("bypass_actors parameter must be an array of objects"), nil
+ }
+ }
+
+ client, err := getClient(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get GitHub client: %w", err)
+ }
+
+ // Build ruleset creation request
+ rulesetReq := map[string]any{
+ "name": name,
+ "enforcement": enforcement,
+ "target": target,
+ "rules": rulesObj,
+ }
+
+ if conditionsObj != nil {
+ rulesetReq["conditions"] = conditionsObj
+ }
+ if bypassActorsObj != nil {
+ rulesetReq["bypass_actors"] = bypassActorsObj
+ }
+
+ // Convert to JSON for the API request
+ jsonData, err := json.Marshal(rulesetReq)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal ruleset request: %w", err)
+ }
+
+ // Make the API request
+ u := fmt.Sprintf("https://api.github.com/orgs/%s/rulesets", url.PathEscape(org))
+
+ // Create a new request with the JSON body
+ req, err := http.NewRequestWithContext(ctx, "POST", u, strings.NewReader(string(jsonData)))
+ if err != nil {
+ return nil, fmt.Errorf("failed to create request: %w", err)
+ }
+
+ req.Header.Set("Accept", "application/vnd.github+json")
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
+
+ // Use the GitHub client's underlying HTTP client to make the request
+ httpClient := client.Client()
+
+ resp, err := httpClient.Do(req)
+ if err != nil {
+ var ghResp *github.Response
+ if resp != nil {
+ ghResp = &github.Response{Response: resp}
+ }
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ "failed to create organization repository ruleset",
+ ghResp,
+ err,
+ ), nil
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read response body: %w", err)
+ }
+
+ if resp.StatusCode != http.StatusCreated {
+ var ghResp *github.Response
+ if resp != nil {
+ ghResp = &github.Response{Response: resp}
+ }
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ "failed to create organization repository ruleset",
+ ghResp,
+ fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body)),
+ ), nil
+ }
+
+ var createdRuleset map[string]any
+ if err := json.Unmarshal(body, &createdRuleset); err != nil {
+ return nil, fmt.Errorf("failed to unmarshal response: %w", err)
+ }
+
+ return MarshalledTextResult(createdRuleset), nil
+ }
+}
+
+// CreateEnterpriseRepositoryRuleset creates a tool to create a new enterprise repository ruleset.
+func CreateEnterpriseRepositoryRuleset(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
+ return mcp.NewTool("create_enterprise_repository_ruleset",
+ mcp.WithDescription(t("TOOL_CREATE_ENTERPRISE_REPOSITORY_RULESET_DESCRIPTION", "Create a new enterprise repository ruleset")),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ Title: t("TOOL_CREATE_ENTERPRISE_REPOSITORY_RULESET_USER_TITLE", "Create enterprise repository ruleset"),
+ ReadOnlyHint: ToBoolPtr(false),
+ }),
+ mcp.WithString("enterprise",
+ mcp.Required(),
+ mcp.Description("Enterprise name"),
+ ),
+ mcp.WithString("name",
+ mcp.Required(),
+ mcp.Description("The name of the ruleset"),
+ ),
+ mcp.WithString("enforcement",
+ mcp.Required(),
+ mcp.Description("The enforcement level of the ruleset. Can be 'disabled', 'active', or 'evaluate'"),
+ ),
+ mcp.WithString("target",
+ mcp.Description("The target of the ruleset. Defaults to 'branch'. Can be one of: 'branch', 'tag', or 'push'"),
+ ),
+ mcp.WithArray("rules",
+ mcp.Required(),
+ mcp.Description("An array of rules within the ruleset"),
+ mcp.Items(
+ map[string]any{
+ "type": "object",
+ },
+ ),
+ ),
+ mcp.WithObject("conditions",
+ mcp.Description("Conditions for when this ruleset applies"),
+ ),
+ mcp.WithArray("bypass_actors",
+ mcp.Description("The actors that can bypass the rules in this ruleset"),
+ mcp.Items(
+ map[string]any{
+ "type": "object",
+ },
+ ),
+ ),
+ ),
+ func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ enterprise, err := RequiredParam[string](request, "enterprise")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ name, err := RequiredParam[string](request, "name")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ enforcement, err := RequiredParam[string](request, "enforcement")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+
+ // Validate enforcement value
+ if enforcement != "disabled" && enforcement != "active" && enforcement != "evaluate" {
+ return mcp.NewToolResultError("enforcement must be one of: 'disabled', 'active', 'evaluate'"), nil
+ }
+
+ // Parse rules parameter - required array
+ rulesObj, ok := request.GetArguments()["rules"].([]interface{})
+ if !ok {
+ return mcp.NewToolResultError("rules parameter must be an array of rule objects"), nil
+ }
+
+ // Optional parameters
+ target, err := OptionalParam[string](request, "target")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ if target == "" {
+ target = "branch" // Default value
+ }
+
+ var conditionsObj map[string]interface{}
+ if conditionsVal, exists := request.GetArguments()["conditions"]; exists {
+ if conditionsMap, ok := conditionsVal.(map[string]interface{}); ok {
+ conditionsObj = conditionsMap
+ } else {
+ return mcp.NewToolResultError("conditions parameter must be an object"), nil
+ }
+ }
+
+ var bypassActorsObj []interface{}
+ if bypassVal, exists := request.GetArguments()["bypass_actors"]; exists {
+ if bypassArr, ok := bypassVal.([]interface{}); ok {
+ bypassActorsObj = bypassArr
+ } else {
+ return mcp.NewToolResultError("bypass_actors parameter must be an array of objects"), nil
+ }
+ }
+
+ client, err := getClient(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get GitHub client: %w", err)
+ }
+
+ // Build ruleset creation request
+ rulesetReq := map[string]any{
+ "name": name,
+ "enforcement": enforcement,
+ "target": target,
+ "rules": rulesObj,
+ }
+
+ if conditionsObj != nil {
+ rulesetReq["conditions"] = conditionsObj
+ }
+ if bypassActorsObj != nil {
+ rulesetReq["bypass_actors"] = bypassActorsObj
+ }
+
+ // Convert to JSON for the API request
+ jsonData, err := json.Marshal(rulesetReq)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal ruleset request: %w", err)
+ }
+
+ // Make the API request
+ u := fmt.Sprintf("https://api.github.com/enterprises/%s/rulesets", url.PathEscape(enterprise))
+
+ // Create a new request with the JSON body
+ req, err := http.NewRequestWithContext(ctx, "POST", u, strings.NewReader(string(jsonData)))
+ if err != nil {
+ return nil, fmt.Errorf("failed to create request: %w", err)
+ }
+
+ req.Header.Set("Accept", "application/vnd.github+json")
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
+
+ // Use the GitHub client's underlying HTTP client to make the request
+ httpClient := client.Client()
+
+ resp, err := httpClient.Do(req)
+ if err != nil {
+ var ghResp *github.Response
+ if resp != nil {
+ ghResp = &github.Response{Response: resp}
+ }
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ "failed to create enterprise repository ruleset",
+ ghResp,
+ err,
+ ), nil
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read response body: %w", err)
+ }
+
+ if resp.StatusCode != http.StatusCreated {
+ var ghResp *github.Response
+ if resp != nil {
+ ghResp = &github.Response{Response: resp}
+ }
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ "failed to create enterprise repository ruleset",
+ ghResp,
+ fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body)),
+ ), nil
+ }
+
+ var createdRuleset map[string]any
+ if err := json.Unmarshal(body, &createdRuleset); err != nil {
+ return nil, fmt.Errorf("failed to unmarshal response: %w", err)
+ }
+
+ return MarshalledTextResult(createdRuleset), nil
+ }
+}
diff --git a/pkg/github/rules_test.go b/pkg/github/rules_test.go
new file mode 100644
index 000000000..23ac57f70
--- /dev/null
+++ b/pkg/github/rules_test.go
@@ -0,0 +1,586 @@
+package github
+
+import (
+ "context"
+ "net/http"
+ "testing"
+
+ "github.com/github/github-mcp-server/internal/toolsnaps"
+ "github.com/github/github-mcp-server/pkg/translations"
+ "github.com/google/go-github/v73/github"
+ "github.com/migueleliasweb/go-github-mock/src/mock"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func Test_GetRepositoryRuleset(t *testing.T) {
+ // Verify tool definition once
+ mockClient := github.NewClient(nil)
+ tool, _ := GetRepositoryRuleset(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+ require.NoError(t, toolsnaps.Test(tool.Name, tool))
+
+ assert.Equal(t, "get_repository_ruleset", tool.Name)
+ assert.NotEmpty(t, tool.Description)
+ assert.Contains(t, tool.InputSchema.Properties, "owner")
+ assert.Contains(t, tool.InputSchema.Properties, "repo")
+ assert.Contains(t, tool.InputSchema.Properties, "rulesetId")
+ assert.Contains(t, tool.InputSchema.Properties, "includesParents")
+ assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "rulesetId"})
+
+ // Setup mock ruleset for success case
+ mockRuleset := &github.RepositoryRuleset{
+ ID: github.Ptr(int64(123)),
+ Name: "test-ruleset",
+ Enforcement: github.RulesetEnforcementActive,
+ Target: github.Ptr(github.RulesetTargetBranch),
+ }
+
+ tests := []struct {
+ name string
+ mockedClient *http.Client
+ requestArgs map[string]interface{}
+ expectError bool
+ expectedRuleset *github.RepositoryRuleset
+ expectedErrMsg string
+ }{
+ {
+ name: "successful ruleset fetch",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatch(
+ mock.GetReposRulesetsByOwnerByRepoByRulesetId,
+ mockRuleset,
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "owner": "testowner",
+ "repo": "testrepo",
+ "rulesetId": float64(123),
+ "includesParents": true,
+ },
+ expectError: false,
+ expectedRuleset: mockRuleset,
+ },
+ {
+ name: "missing required parameter owner",
+ mockedClient: mock.NewMockedHTTPClient(),
+ requestArgs: map[string]interface{}{
+ "repo": "testrepo",
+ "rulesetId": float64(123),
+ },
+ expectError: true,
+ expectedErrMsg: "missing required parameter: owner",
+ },
+ {
+ name: "missing required parameter repo",
+ mockedClient: mock.NewMockedHTTPClient(),
+ requestArgs: map[string]interface{}{
+ "owner": "testowner",
+ "rulesetId": float64(123),
+ },
+ expectError: true,
+ expectedErrMsg: "missing required parameter: repo",
+ },
+ {
+ name: "missing required parameter rulesetId",
+ mockedClient: mock.NewMockedHTTPClient(),
+ requestArgs: map[string]interface{}{
+ "owner": "testowner",
+ "repo": "testrepo",
+ },
+ expectError: true,
+ expectedErrMsg: "missing required parameter: rulesetId",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ client := github.NewClient(tt.mockedClient)
+ _, handler := GetRepositoryRuleset(stubGetClientFn(client), translations.NullTranslationHelper)
+
+ result, err := handler(context.Background(), createMCPRequest(tt.requestArgs))
+
+ if tt.expectError {
+ assert.Nil(t, err)
+ assert.NotNil(t, result)
+ assert.True(t, result.IsError)
+ if tt.expectedErrMsg != "" {
+ textResult := getErrorResult(t, result)
+ assert.Contains(t, textResult.Text, tt.expectedErrMsg)
+ }
+ } else {
+ assert.Nil(t, err)
+ assert.NotNil(t, result)
+ assert.False(t, result.IsError)
+ textResult := getTextResult(t, result)
+ assert.NotEmpty(t, textResult.Text)
+ }
+ })
+ }
+}
+
+func Test_ListRepositoryRulesets(t *testing.T) {
+ // Verify tool definition once
+ mockClient := github.NewClient(nil)
+ tool, _ := ListRepositoryRulesets(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+ require.NoError(t, toolsnaps.Test(tool.Name, tool))
+
+ assert.Equal(t, "list_repository_rulesets", tool.Name)
+ assert.NotEmpty(t, tool.Description)
+ assert.Contains(t, tool.InputSchema.Properties, "owner")
+ assert.Contains(t, tool.InputSchema.Properties, "repo")
+ assert.Contains(t, tool.InputSchema.Properties, "includesParents")
+ assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"})
+
+ // Setup mock rulesets for success case
+ mockRulesets := []*github.RepositoryRuleset{
+ {
+ ID: github.Ptr(int64(123)),
+ Name: "test-ruleset-1",
+ },
+ {
+ ID: github.Ptr(int64(456)),
+ Name: "test-ruleset-2",
+ },
+ }
+
+ tests := []struct {
+ name string
+ mockedClient *http.Client
+ requestArgs map[string]interface{}
+ expectError bool
+ expectedErrMsg string
+ }{
+ {
+ name: "successful rulesets listing",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatch(
+ mock.GetReposRulesetsByOwnerByRepo,
+ mockRulesets,
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "owner": "testowner",
+ "repo": "testrepo",
+ "includesParents": false,
+ },
+ expectError: false,
+ },
+ {
+ name: "missing required parameter owner",
+ mockedClient: mock.NewMockedHTTPClient(),
+ requestArgs: map[string]interface{}{
+ "repo": "testrepo",
+ },
+ expectError: true,
+ expectedErrMsg: "missing required parameter: owner",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ client := github.NewClient(tt.mockedClient)
+ _, handler := ListRepositoryRulesets(stubGetClientFn(client), translations.NullTranslationHelper)
+
+ result, err := handler(context.Background(), createMCPRequest(tt.requestArgs))
+
+ if tt.expectError {
+ assert.Nil(t, err)
+ assert.NotNil(t, result)
+ assert.True(t, result.IsError)
+ if tt.expectedErrMsg != "" {
+ textResult := getErrorResult(t, result)
+ assert.Contains(t, textResult.Text, tt.expectedErrMsg)
+ }
+ } else {
+ assert.Nil(t, err)
+ assert.NotNil(t, result)
+ assert.False(t, result.IsError)
+ textResult := getTextResult(t, result)
+ assert.NotEmpty(t, textResult.Text)
+ }
+ })
+ }
+}
+
+func Test_GetRepositoryRulesForBranch(t *testing.T) {
+ // Verify tool definition once
+ mockClient := github.NewClient(nil)
+ tool, _ := GetRepositoryRulesForBranch(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+ require.NoError(t, toolsnaps.Test(tool.Name, tool))
+
+ assert.Equal(t, "get_repository_rules_for_branch", tool.Name)
+ assert.NotEmpty(t, tool.Description)
+ assert.Contains(t, tool.InputSchema.Properties, "owner")
+ assert.Contains(t, tool.InputSchema.Properties, "repo")
+ assert.Contains(t, tool.InputSchema.Properties, "branch")
+ assert.Contains(t, tool.InputSchema.Properties, "page")
+ assert.Contains(t, tool.InputSchema.Properties, "perPage")
+ assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "branch"})
+
+ tests := []struct {
+ name string
+ mockedClient *http.Client
+ requestArgs map[string]interface{}
+ expectError bool
+ expectedErrMsg string
+ }{
+ // TODO: Fix this test - the mock response format doesn't match the expected branchRuleWrapper array format
+ // {
+ // name: "successful branch rules fetch",
+ // mockedClient: mock.NewMockedHTTPClient(
+ // mock.WithRequestMatch(
+ // mock.GetReposRulesBranchesByOwnerByRepoByBranch,
+ // mockBranchRules,
+ // ),
+ // ),
+ // requestArgs: map[string]interface{}{
+ // "owner": "testowner",
+ // "repo": "testrepo",
+ // "branch": "main",
+ // },
+ // expectError: false,
+ // },
+ {
+ name: "missing required parameter branch",
+ mockedClient: mock.NewMockedHTTPClient(),
+ requestArgs: map[string]interface{}{
+ "owner": "testowner",
+ "repo": "testrepo",
+ },
+ expectError: true,
+ expectedErrMsg: "missing required parameter: branch",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ client := github.NewClient(tt.mockedClient)
+ _, handler := GetRepositoryRulesForBranch(stubGetClientFn(client), translations.NullTranslationHelper)
+
+ result, err := handler(context.Background(), createMCPRequest(tt.requestArgs))
+
+ if tt.expectError {
+ assert.Nil(t, err)
+ assert.NotNil(t, result)
+ assert.True(t, result.IsError)
+ if tt.expectedErrMsg != "" {
+ textResult := getErrorResult(t, result)
+ assert.Contains(t, textResult.Text, tt.expectedErrMsg)
+ }
+ } else {
+ assert.Nil(t, err)
+ assert.NotNil(t, result)
+ assert.False(t, result.IsError)
+ textResult := getTextResult(t, result)
+ assert.NotEmpty(t, textResult.Text)
+ }
+ })
+ }
+}
+
+func Test_GetOrganizationRepositoryRuleset(t *testing.T) {
+ // Verify tool definition once
+ mockClient := github.NewClient(nil)
+ tool, _ := GetOrganizationRepositoryRuleset(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+ require.NoError(t, toolsnaps.Test(tool.Name, tool))
+
+ assert.Equal(t, "get_organization_repository_ruleset", tool.Name)
+ assert.NotEmpty(t, tool.Description)
+ assert.Contains(t, tool.InputSchema.Properties, "org")
+ assert.Contains(t, tool.InputSchema.Properties, "rulesetId")
+ assert.ElementsMatch(t, tool.InputSchema.Required, []string{"org", "rulesetId"})
+
+ // Setup mock organization ruleset for success case
+ mockRuleset := &github.RepositoryRuleset{
+ ID: github.Ptr(int64(789)),
+ Name: "org-test-ruleset",
+ Enforcement: github.RulesetEnforcementActive,
+ Target: github.Ptr(github.RulesetTargetBranch),
+ }
+
+ tests := []struct {
+ name string
+ mockedClient *http.Client
+ requestArgs map[string]interface{}
+ expectError bool
+ expectedErrMsg string
+ }{
+ {
+ name: "successful organization ruleset fetch",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatch(
+ mock.GetOrgsRulesetsByOrgByRulesetId,
+ mockRuleset,
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "org": "testorg",
+ "rulesetId": float64(789),
+ },
+ expectError: false,
+ },
+ {
+ name: "missing required parameter org",
+ mockedClient: mock.NewMockedHTTPClient(),
+ requestArgs: map[string]interface{}{
+ "rulesetId": float64(789),
+ },
+ expectError: true,
+ expectedErrMsg: "missing required parameter: org",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ client := github.NewClient(tt.mockedClient)
+ _, handler := GetOrganizationRepositoryRuleset(stubGetClientFn(client), translations.NullTranslationHelper)
+
+ result, err := handler(context.Background(), createMCPRequest(tt.requestArgs))
+
+ if tt.expectError {
+ assert.Nil(t, err)
+ assert.NotNil(t, result)
+ assert.True(t, result.IsError)
+ if tt.expectedErrMsg != "" {
+ textResult := getErrorResult(t, result)
+ assert.Contains(t, textResult.Text, tt.expectedErrMsg)
+ }
+ } else {
+ assert.Nil(t, err)
+ assert.NotNil(t, result)
+ assert.False(t, result.IsError)
+ textResult := getTextResult(t, result)
+ assert.NotEmpty(t, textResult.Text)
+ }
+ })
+ }
+}
+
+func Test_ListOrganizationRepositoryRulesets(t *testing.T) {
+ // Verify tool definition once
+ mockClient := github.NewClient(nil)
+ tool, _ := ListOrganizationRepositoryRulesets(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+ require.NoError(t, toolsnaps.Test(tool.Name, tool))
+
+ assert.Equal(t, "list_organization_repository_rulesets", tool.Name)
+ assert.NotEmpty(t, tool.Description)
+ assert.Contains(t, tool.InputSchema.Properties, "org")
+ assert.Contains(t, tool.InputSchema.Properties, "page")
+ assert.Contains(t, tool.InputSchema.Properties, "perPage")
+ assert.ElementsMatch(t, tool.InputSchema.Required, []string{"org"})
+
+ // Setup mock organization rulesets for success case
+ mockRulesets := []*github.RepositoryRuleset{
+ {
+ ID: github.Ptr(int64(789)),
+ Name: "org-test-ruleset-1",
+ },
+ {
+ ID: github.Ptr(int64(790)),
+ Name: "org-test-ruleset-2",
+ },
+ }
+
+ tests := []struct {
+ name string
+ mockedClient *http.Client
+ requestArgs map[string]interface{}
+ expectError bool
+ expectedErrMsg string
+ }{
+ {
+ name: "successful organization rulesets listing",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatch(
+ mock.GetOrgsRulesetsByOrg,
+ mockRulesets,
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "org": "testorg",
+ },
+ expectError: false,
+ },
+ {
+ name: "missing required parameter org",
+ mockedClient: mock.NewMockedHTTPClient(),
+ requestArgs: map[string]interface{}{},
+ expectError: true,
+ expectedErrMsg: "missing required parameter: org",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ client := github.NewClient(tt.mockedClient)
+ _, handler := ListOrganizationRepositoryRulesets(stubGetClientFn(client), translations.NullTranslationHelper)
+
+ result, err := handler(context.Background(), createMCPRequest(tt.requestArgs))
+
+ if tt.expectError {
+ assert.Nil(t, err)
+ assert.NotNil(t, result)
+ assert.True(t, result.IsError)
+ if tt.expectedErrMsg != "" {
+ textResult := getErrorResult(t, result)
+ assert.Contains(t, textResult.Text, tt.expectedErrMsg)
+ }
+ } else {
+ assert.Nil(t, err)
+ assert.NotNil(t, result)
+ assert.False(t, result.IsError)
+ textResult := getTextResult(t, result)
+ assert.NotEmpty(t, textResult.Text)
+ }
+ })
+ }
+}
+
+func Test_ListRepositoryRuleSuites(t *testing.T) {
+ // Verify tool definition once
+ mockClient := github.NewClient(nil)
+ tool, _ := ListRepositoryRuleSuites(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+ require.NoError(t, toolsnaps.Test(tool.Name, tool))
+
+ assert.Equal(t, "list_repository_rule_suites", tool.Name)
+ assert.NotEmpty(t, tool.Description)
+ assert.Contains(t, tool.InputSchema.Properties, "owner")
+ assert.Contains(t, tool.InputSchema.Properties, "repo")
+ assert.Contains(t, tool.InputSchema.Properties, "ref")
+ assert.Contains(t, tool.InputSchema.Properties, "timePeriod")
+ assert.Contains(t, tool.InputSchema.Properties, "actorName")
+ assert.Contains(t, tool.InputSchema.Properties, "ruleSuiteResult")
+ assert.Contains(t, tool.InputSchema.Properties, "page")
+ assert.Contains(t, tool.InputSchema.Properties, "perPage")
+ assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"})
+
+ tests := []struct {
+ name string
+ mockedClient *http.Client
+ requestArgs map[string]interface{}
+ expectError bool
+ expectedErrMsg string
+ }{
+ {
+ name: "missing required parameter owner",
+ mockedClient: mock.NewMockedHTTPClient(),
+ requestArgs: map[string]interface{}{
+ "repo": "testrepo",
+ },
+ expectError: true,
+ expectedErrMsg: "missing required parameter: owner",
+ },
+ {
+ name: "missing required parameter repo",
+ mockedClient: mock.NewMockedHTTPClient(),
+ requestArgs: map[string]interface{}{
+ "owner": "testowner",
+ },
+ expectError: true,
+ expectedErrMsg: "missing required parameter: repo",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ client := github.NewClient(tt.mockedClient)
+ _, handler := ListRepositoryRuleSuites(stubGetClientFn(client), translations.NullTranslationHelper)
+
+ result, err := handler(context.Background(), createMCPRequest(tt.requestArgs))
+
+ if tt.expectError {
+ assert.Nil(t, err)
+ assert.NotNil(t, result)
+ assert.True(t, result.IsError)
+ if tt.expectedErrMsg != "" {
+ textResult := getErrorResult(t, result)
+ assert.Contains(t, textResult.Text, tt.expectedErrMsg)
+ }
+ } else {
+ assert.Nil(t, err)
+ assert.NotNil(t, result)
+ assert.False(t, result.IsError)
+ textResult := getTextResult(t, result)
+ assert.NotEmpty(t, textResult.Text)
+ }
+ })
+ }
+}
+
+func Test_GetRepositoryRuleSuite(t *testing.T) {
+ // Verify tool definition once
+ mockClient := github.NewClient(nil)
+ tool, _ := GetRepositoryRuleSuite(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+ require.NoError(t, toolsnaps.Test(tool.Name, tool))
+
+ assert.Equal(t, "get_repository_rule_suite", tool.Name)
+ assert.NotEmpty(t, tool.Description)
+ assert.Contains(t, tool.InputSchema.Properties, "owner")
+ assert.Contains(t, tool.InputSchema.Properties, "repo")
+ assert.Contains(t, tool.InputSchema.Properties, "ruleSuiteId")
+ assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "ruleSuiteId"})
+
+ tests := []struct {
+ name string
+ mockedClient *http.Client
+ requestArgs map[string]interface{}
+ expectError bool
+ expectedErrMsg string
+ }{
+ {
+ name: "missing required parameter owner",
+ mockedClient: mock.NewMockedHTTPClient(),
+ requestArgs: map[string]interface{}{
+ "repo": "testrepo",
+ "ruleSuiteId": float64(456),
+ },
+ expectError: true,
+ expectedErrMsg: "missing required parameter: owner",
+ },
+ {
+ name: "missing required parameter repo",
+ mockedClient: mock.NewMockedHTTPClient(),
+ requestArgs: map[string]interface{}{
+ "owner": "testowner",
+ "ruleSuiteId": float64(456),
+ },
+ expectError: true,
+ expectedErrMsg: "missing required parameter: repo",
+ },
+ {
+ name: "missing required parameter ruleSuiteId",
+ mockedClient: mock.NewMockedHTTPClient(),
+ requestArgs: map[string]interface{}{
+ "owner": "testowner",
+ "repo": "testrepo",
+ },
+ expectError: true,
+ expectedErrMsg: "missing required parameter: ruleSuiteId",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ client := github.NewClient(tt.mockedClient)
+ _, handler := GetRepositoryRuleSuite(stubGetClientFn(client), translations.NullTranslationHelper)
+
+ result, err := handler(context.Background(), createMCPRequest(tt.requestArgs))
+
+ if tt.expectError {
+ assert.Nil(t, err)
+ assert.NotNil(t, result)
+ assert.True(t, result.IsError)
+ if tt.expectedErrMsg != "" {
+ textResult := getErrorResult(t, result)
+ assert.Contains(t, textResult.Text, tt.expectedErrMsg)
+ }
+ } else {
+ assert.Nil(t, err)
+ assert.NotNil(t, result)
+ assert.False(t, result.IsError)
+ textResult := getTextResult(t, result)
+ assert.NotEmpty(t, textResult.Text)
+ }
+ })
+ }
+}
diff --git a/pkg/github/tools.go b/pkg/github/tools.go
index 7fb1d39c0..a2daa9a8c 100644
--- a/pkg/github/tools.go
+++ b/pkg/github/tools.go
@@ -31,6 +31,13 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG
toolsets.NewServerTool(ListBranches(getClient, t)),
toolsets.NewServerTool(ListTags(getClient, t)),
toolsets.NewServerTool(GetTag(getClient, t)),
+ // Repository rulesets and rules
+ toolsets.NewServerTool(GetRepositoryRuleset(getClient, t)),
+ toolsets.NewServerTool(ListRepositoryRulesets(getClient, t)),
+ toolsets.NewServerTool(GetRepositoryRulesForBranch(getClient, t)),
+ // Repository rule suites
+ toolsets.NewServerTool(ListRepositoryRuleSuites(getClient, t)),
+ toolsets.NewServerTool(GetRepositoryRuleSuite(getClient, t)),
).
AddWriteTools(
toolsets.NewServerTool(CreateOrUpdateFile(getClient, t)),
@@ -39,6 +46,7 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG
toolsets.NewServerTool(CreateBranch(getClient, t)),
toolsets.NewServerTool(PushFiles(getClient, t)),
toolsets.NewServerTool(DeleteFile(getClient, t)),
+ toolsets.NewServerTool(CreateRepositoryRuleset(getClient, t)),
).
AddResourceTemplates(
toolsets.NewServerResourceTemplate(GetRepositoryResourceContent(getClient, getRawClient, t)),
@@ -46,7 +54,15 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG
toolsets.NewServerResourceTemplate(GetRepositoryResourceCommitContent(getClient, getRawClient, t)),
toolsets.NewServerResourceTemplate(GetRepositoryResourceTagContent(getClient, getRawClient, t)),
toolsets.NewServerResourceTemplate(GetRepositoryResourcePrContent(getClient, getRawClient, t)),
- )
+ ).
+ AddReadTools(func() server.ServerTool {
+ tool, handler := GetRepositoryCustomProperties(getClient, t)
+ return toolsets.NewServerTool(tool, handler)
+ }()).
+ AddWriteTools(func() server.ServerTool {
+ tool, handler := CreateOrUpdateRepositoryCustomProperties(getClient, t)
+ return toolsets.NewServerTool(tool, handler)
+ }())
issues := toolsets.NewToolset("issues", "GitHub Issues related tools").
AddReadTools(
toolsets.NewServerTool(GetIssue(getClient, t)),
@@ -74,7 +90,33 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG
orgs := toolsets.NewToolset("orgs", "GitHub Organization related tools").
AddReadTools(
toolsets.NewServerTool(SearchOrgs(getClient, t)),
- )
+ // Organization repository rulesets
+ toolsets.NewServerTool(GetOrganizationRepositoryRuleset(getClient, t)),
+ toolsets.NewServerTool(ListOrganizationRepositoryRulesets(getClient, t)),
+ ).
+ AddWriteTools(
+ toolsets.NewServerTool(CreateOrganizationRepositoryRuleset(getClient, t)),
+ ).
+ AddReadTools(func() server.ServerTool {
+ tool, handler := GetOrganizationCustomProperties(getClient, t)
+ return toolsets.NewServerTool(tool, handler)
+ }()).
+ AddWriteTools(func() server.ServerTool {
+ tool, handler := CreateOrUpdateOrganizationCustomProperties(getClient, t)
+ return toolsets.NewServerTool(tool, handler)
+ }())
+ enterprise := toolsets.NewToolset("enterprise", "GitHub Enterprise related tools").
+ AddWriteTools(
+ toolsets.NewServerTool(CreateEnterpriseRepositoryRuleset(getClient, t)),
+ ).
+ AddReadTools(func() server.ServerTool {
+ tool, handler := GetEnterpriseCustomProperties(getClient, t)
+ return toolsets.NewServerTool(tool, handler)
+ }()).
+ AddWriteTools(func() server.ServerTool {
+ tool, handler := CreateOrUpdateEnterpriseCustomProperties(getClient, t)
+ return toolsets.NewServerTool(tool, handler)
+ }())
pullRequests := toolsets.NewToolset("pull_requests", "GitHub Pull Request related tools").
AddReadTools(
toolsets.NewServerTool(GetPullRequest(getClient, t)),
@@ -178,6 +220,7 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG
tsg.AddToolset(repos)
tsg.AddToolset(issues)
tsg.AddToolset(orgs)
+ tsg.AddToolset(enterprise)
tsg.AddToolset(users)
tsg.AddToolset(pullRequests)
tsg.AddToolset(actions)