diff --git a/README.md b/README.md
index 690a5cbd9a..1980ed148e 100644
--- a/README.md
+++ b/README.md
@@ -586,7 +586,7 @@ The following sets of tools are available:
|
| `discussions` | GitHub Discussions related tools |
|
| `gists` | GitHub Gist related tools |
|
| `git` | GitHub Git API related tools for low-level Git operations |
-|
| `governance` | Repository governance tools for managing rulesets at the repository, organization, and enterprise levels |
+|
| `governance` | Repository governance tools for managing rulesets and custom properties at the repository, organization, and enterprise levels |
|
| `issues` | GitHub Issues related tools |
|
| `labels` | GitHub Labels related tools |
|
| `notifications` | GitHub Notifications related tools |
@@ -892,6 +892,29 @@ The following sets of tools are available:
- `rules`: An array of rules within the ruleset. Each rule is an object with a 'type' (e.g. 'creation', 'deletion', 'non_fast_forward', 'required_signatures', 'pull_request', 'required_status_checks') and, for rules that need configuration, a 'parameters' object (object[], required)
- `target`: The target of the ruleset. Defaults to 'branch'. 'repository' is only valid for 'organization' and 'enterprise' level rulesets. (string, optional)
+- **custom_properties_read** - Read custom properties
+ - **OAuth Challenge Scopes**: `repo`, `read:org`, `read:enterprise`
+ - `enterprise`: Enterprise slug. Required when level is 'enterprise'. (string, optional)
+ - `level`: The level at which custom properties are managed:
+ - 'repository': The custom property VALUES assigned to a repository (requires 'owner' and 'repo').
+ - 'organization': The custom property DEFINITIONS (schema) for an organization (requires 'org').
+ - 'enterprise': The custom property DEFINITIONS (schema) for an enterprise (requires 'enterprise'). (string, required)
+ - `org`: Organization name. Required when level is 'organization'. (string, optional)
+ - `owner`: Repository owner. Required when level is 'repository'. (string, optional)
+ - `repo`: Repository name. Required when level is 'repository'. (string, optional)
+
+- **custom_properties_write** - Set custom properties
+ - **OAuth Challenge Scopes**: `repo`, `admin:org`, `admin:enterprise`
+ - `enterprise`: Enterprise slug. Required when level is 'enterprise'. (string, optional)
+ - `level`: The level at which custom properties are managed:
+ - 'repository': The custom property VALUES assigned to a repository (requires 'owner' and 'repo').
+ - 'organization': The custom property DEFINITIONS (schema) for an organization (requires 'org').
+ - 'enterprise': The custom property DEFINITIONS (schema) for an enterprise (requires 'enterprise'). (string, required)
+ - `org`: Organization name. Required when level is 'organization'. (string, optional)
+ - `owner`: Repository owner. Required when level is 'repository'. (string, optional)
+ - `properties`: The custom properties to create or update. At the repository level each item assigns a value ('property_name' and 'value'); at the organization and enterprise levels each item defines the schema ('property_name' and 'value_type', plus optional definition fields). (object[], required)
+ - `repo`: Repository name. Required when level is 'repository'. (string, optional)
+
- **repository_ruleset_read** - Read repository rulesets
- **OAuth Challenge Scopes**: `repo`, `read:org`, `read:enterprise`
- `actor_name`: The handle for the GitHub user account to filter rule suites on. Used by the 'list_rule_suites' method. (string, optional)
diff --git a/docs/remote-server.md b/docs/remote-server.md
index 78b5c906ac..be91009148 100644
--- a/docs/remote-server.md
+++ b/docs/remote-server.md
@@ -30,7 +30,7 @@ Below is a table of available toolsets for the remote GitHub MCP Server. Each to
| 
`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) |
| 
`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) |
| 
`git` | GitHub Git API related tools for low-level Git operations | https://api.githubcopilot.com/mcp/x/git | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-git&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgit%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/git/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-git&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgit%2Freadonly%22%7D) |
-| 
`governance` | Repository governance tools for managing rulesets at the repository, organization, and enterprise levels | https://api.githubcopilot.com/mcp/x/governance | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-governance&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgovernance%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/governance/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-governance&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgovernance%2Freadonly%22%7D) |
+| 
`governance` | Repository governance tools for managing rulesets and custom properties at the repository, organization, and enterprise levels | https://api.githubcopilot.com/mcp/x/governance | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-governance&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgovernance%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/governance/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-governance&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgovernance%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) |
| 
`labels` | GitHub Labels related tools | https://api.githubcopilot.com/mcp/x/labels | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-labels&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Flabels%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/labels/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-labels&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Flabels%2Freadonly%22%7D) |
| 
`notifications` | GitHub Notifications related tools | https://api.githubcopilot.com/mcp/x/notifications | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-notifications&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fnotifications%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/notifications/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-notifications&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fnotifications%2Freadonly%22%7D) |
diff --git a/pkg/github/__toolsnaps__/custom_properties_read.snap b/pkg/github/__toolsnaps__/custom_properties_read.snap
new file mode 100644
index 0000000000..9a7ff10e9b
--- /dev/null
+++ b/pkg/github/__toolsnaps__/custom_properties_read.snap
@@ -0,0 +1,42 @@
+{
+ "annotations": {
+ "idempotentHint": false,
+ "readOnlyHint": true,
+ "title": "Read custom properties"
+ },
+ "description": "Read custom properties at the repository, organization, or enterprise level. At the repository level this returns the property values assigned to a repository; at the organization and enterprise levels it returns the property definitions (schema). Select the level with the 'level' parameter.",
+ "inputSchema": {
+ "properties": {
+ "enterprise": {
+ "description": "Enterprise slug. Required when level is 'enterprise'.",
+ "type": "string"
+ },
+ "level": {
+ "description": "The level at which custom properties are managed:\n- 'repository': The custom property VALUES assigned to a repository (requires 'owner' and 'repo').\n- 'organization': The custom property DEFINITIONS (schema) for an organization (requires 'org').\n- 'enterprise': The custom property DEFINITIONS (schema) for an enterprise (requires 'enterprise').",
+ "enum": [
+ "repository",
+ "organization",
+ "enterprise"
+ ],
+ "type": "string"
+ },
+ "org": {
+ "description": "Organization name. Required when level is 'organization'.",
+ "type": "string"
+ },
+ "owner": {
+ "description": "Repository owner. Required when level is 'repository'.",
+ "type": "string"
+ },
+ "repo": {
+ "description": "Repository name. Required when level is 'repository'.",
+ "type": "string"
+ }
+ },
+ "required": [
+ "level"
+ ],
+ "type": "object"
+ },
+ "name": "custom_properties_read"
+}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/custom_properties_write.snap b/pkg/github/__toolsnaps__/custom_properties_write.snap
new file mode 100644
index 0000000000..e5b63b12a9
--- /dev/null
+++ b/pkg/github/__toolsnaps__/custom_properties_write.snap
@@ -0,0 +1,99 @@
+{
+ "annotations": {
+ "idempotentHint": false,
+ "readOnlyHint": false,
+ "title": "Set custom properties"
+ },
+ "description": "Create or update custom properties at the repository, organization, or enterprise level. At the repository level this sets the property values on a repository (the properties must already be defined for the organization); at the organization and enterprise levels it creates or updates the property definitions (schema). Select the level with the 'level' parameter.",
+ "inputSchema": {
+ "properties": {
+ "enterprise": {
+ "description": "Enterprise slug. Required when level is 'enterprise'.",
+ "type": "string"
+ },
+ "level": {
+ "description": "The level at which custom properties are managed:\n- 'repository': The custom property VALUES assigned to a repository (requires 'owner' and 'repo').\n- 'organization': The custom property DEFINITIONS (schema) for an organization (requires 'org').\n- 'enterprise': The custom property DEFINITIONS (schema) for an enterprise (requires 'enterprise').",
+ "enum": [
+ "repository",
+ "organization",
+ "enterprise"
+ ],
+ "type": "string"
+ },
+ "org": {
+ "description": "Organization name. Required when level is 'organization'.",
+ "type": "string"
+ },
+ "owner": {
+ "description": "Repository owner. Required when level is 'repository'.",
+ "type": "string"
+ },
+ "properties": {
+ "description": "The custom properties to create or update. At the repository level each item assigns a value ('property_name' and 'value'); at the organization and enterprise levels each item defines the schema ('property_name' and 'value_type', plus optional definition fields).",
+ "items": {
+ "properties": {
+ "allowed_values": {
+ "description": "Organization and enterprise levels only: the ordered list of allowed values for single_select and multi_select properties.",
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
+ "default_value": {
+ "description": "Organization and enterprise levels only: the value applied when a repository does not set the property. A string or an array of strings."
+ },
+ "description": {
+ "description": "Organization and enterprise levels only: a short description of the property.",
+ "type": "string"
+ },
+ "property_name": {
+ "description": "The name of the custom property.",
+ "type": "string"
+ },
+ "required": {
+ "description": "Organization and enterprise levels only: whether the property must be set on every repository.",
+ "type": "boolean"
+ },
+ "value": {
+ "description": "Repository level only: the value to assign. A string, an array of strings, or null to clear the value."
+ },
+ "value_type": {
+ "description": "Organization and enterprise levels only: the data type of the property. Required when defining a property.",
+ "enum": [
+ "string",
+ "single_select",
+ "multi_select",
+ "true_false",
+ "url"
+ ],
+ "type": "string"
+ },
+ "values_editable_by": {
+ "description": "Organization and enterprise levels only: who can edit the values of the property.",
+ "enum": [
+ "org_actors",
+ "org_and_repo_actors"
+ ],
+ "type": "string"
+ }
+ },
+ "required": [
+ "property_name"
+ ],
+ "type": "object"
+ },
+ "type": "array"
+ },
+ "repo": {
+ "description": "Repository name. Required when level is 'repository'.",
+ "type": "string"
+ }
+ },
+ "required": [
+ "level",
+ "properties"
+ ],
+ "type": "object"
+ },
+ "name": "custom_properties_write"
+}
\ 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 0000000000..42c217324c
--- /dev/null
+++ b/pkg/github/custom_properties.go
@@ -0,0 +1,363 @@
+package github
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+
+ ghErrors "github.com/github/github-mcp-server/pkg/errors"
+ "github.com/github/github-mcp-server/pkg/inventory"
+ "github.com/github/github-mcp-server/pkg/translations"
+ "github.com/github/github-mcp-server/pkg/utils"
+ "github.com/google/go-github/v89/github"
+ "github.com/google/jsonschema-go/jsonschema"
+ "github.com/modelcontextprotocol/go-sdk/mcp"
+)
+
+// customPropertiesLevelDescription documents the "level" parameter shared by
+// the custom properties read and write tools. The repository level operates on
+// the property VALUES assigned to a repository, while the organization and
+// enterprise levels operate on the property DEFINITIONS (schema).
+const customPropertiesLevelDescription = "The level at which custom properties are managed:\n" +
+ "- 'repository': The custom property VALUES assigned to a repository (requires 'owner' and 'repo').\n" +
+ "- 'organization': The custom property DEFINITIONS (schema) for an organization (requires 'org').\n" +
+ "- 'enterprise': The custom property DEFINITIONS (schema) for an enterprise (requires 'enterprise')."
+
+// CustomPropertiesRead creates a tool for read operations on custom properties
+// at the repository, organization, or enterprise level. The level is selected
+// with the "level" parameter. Repository reads return property values;
+// organization and enterprise reads return property definitions.
+func CustomPropertiesRead(t translations.TranslationHelperFunc) inventory.ServerTool {
+ return NewTool(
+ ToolsetMetadataGovernance,
+ mcp.Tool{
+ Name: "custom_properties_read",
+ Description: t("TOOL_CUSTOM_PROPERTIES_READ_DESCRIPTION", "Read custom properties at the repository, organization, or enterprise level. At the repository level this returns the property values assigned to a repository; at the organization and enterprise levels it returns the property definitions (schema). Select the level with the 'level' parameter."),
+ Annotations: &mcp.ToolAnnotations{
+ Title: t("TOOL_CUSTOM_PROPERTIES_READ_USER_TITLE", "Read custom properties"),
+ ReadOnlyHint: true,
+ },
+ InputSchema: &jsonschema.Schema{
+ Type: "object",
+ Properties: map[string]*jsonschema.Schema{
+ "level": {
+ Type: "string",
+ Enum: []any{"repository", "organization", "enterprise"},
+ Description: customPropertiesLevelDescription,
+ },
+ "owner": {
+ Type: "string",
+ Description: "Repository owner. Required when level is 'repository'.",
+ },
+ "repo": {
+ Type: "string",
+ Description: "Repository name. Required when level is 'repository'.",
+ },
+ "org": {
+ Type: "string",
+ Description: "Organization name. Required when level is 'organization'.",
+ },
+ "enterprise": {
+ Type: "string",
+ Description: "Enterprise slug. Required when level is 'enterprise'.",
+ },
+ },
+ Required: []string{"level"},
+ },
+ },
+ governanceReadScopeAccess(),
+ func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
+ level, err := RequiredParam[string](args, "level")
+ if err != nil {
+ return utils.NewToolResultError(err.Error()), nil, nil
+ }
+
+ client, err := deps.GetClient(ctx)
+ if err != nil {
+ return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err)
+ }
+
+ switch level {
+ case "repository":
+ return customPropertiesReadRepository(ctx, client, args)
+ case "organization":
+ return customPropertiesReadOrganization(ctx, client, args)
+ case "enterprise":
+ return customPropertiesReadEnterprise(ctx, client, args)
+ default:
+ return utils.NewToolResultError(fmt.Sprintf("unknown level: %q (expected 'repository', 'organization', or 'enterprise')", level)), nil, nil
+ }
+ },
+ )
+}
+
+// customPropertiesReadRepository handles custom_properties_read calls with level="repository".
+func customPropertiesReadRepository(ctx context.Context, client *github.Client, args map[string]any) (*mcp.CallToolResult, any, error) {
+ owner, err := RequiredParam[string](args, "owner")
+ if err != nil {
+ return utils.NewToolResultError(err.Error()), nil, nil
+ }
+ repo, err := RequiredParam[string](args, "repo")
+ if err != nil {
+ return utils.NewToolResultError(err.Error()), nil, nil
+ }
+
+ properties, resp, err := client.Repositories.GetAllCustomPropertyValues(ctx, owner, repo)
+ if resp != nil {
+ defer func() { _ = resp.Body.Close() }()
+ }
+ if err != nil {
+ return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get repository custom property values", resp, err), nil, nil
+ }
+
+ return MarshalledTextResult(properties), nil, nil
+}
+
+// customPropertiesReadOrganization handles custom_properties_read calls with level="organization".
+func customPropertiesReadOrganization(ctx context.Context, client *github.Client, args map[string]any) (*mcp.CallToolResult, any, error) {
+ org, err := RequiredParam[string](args, "org")
+ if err != nil {
+ return utils.NewToolResultError(err.Error()), nil, nil
+ }
+
+ properties, resp, err := client.Organizations.GetAllCustomProperties(ctx, org)
+ if resp != nil {
+ defer func() { _ = resp.Body.Close() }()
+ }
+ if err != nil {
+ return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get organization custom properties", resp, err), nil, nil
+ }
+
+ return MarshalledTextResult(properties), nil, nil
+}
+
+// customPropertiesReadEnterprise handles custom_properties_read calls with level="enterprise".
+func customPropertiesReadEnterprise(ctx context.Context, client *github.Client, args map[string]any) (*mcp.CallToolResult, any, error) {
+ enterprise, err := RequiredParam[string](args, "enterprise")
+ if err != nil {
+ return utils.NewToolResultError(err.Error()), nil, nil
+ }
+
+ properties, resp, err := client.Enterprise.GetAllCustomProperties(ctx, enterprise)
+ if resp != nil {
+ defer func() { _ = resp.Body.Close() }()
+ }
+ if err != nil {
+ return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get enterprise custom properties", resp, err), nil, nil
+ }
+
+ return MarshalledTextResult(properties), nil, nil
+}
+
+// CustomPropertiesWrite creates a tool for create-or-update operations on
+// custom properties at the repository, organization, or enterprise level. The
+// level is selected with the "level" parameter. Repository writes set property
+// values; organization and enterprise writes define property schemas.
+func CustomPropertiesWrite(t translations.TranslationHelperFunc) inventory.ServerTool {
+ return NewTool(
+ ToolsetMetadataGovernance,
+ mcp.Tool{
+ Name: "custom_properties_write",
+ Description: t("TOOL_CUSTOM_PROPERTIES_WRITE_DESCRIPTION", "Create or update custom properties at the repository, organization, or enterprise level. At the repository level this sets the property values on a repository (the properties must already be defined for the organization); at the organization and enterprise levels it creates or updates the property definitions (schema). Select the level with the 'level' parameter."),
+ Annotations: &mcp.ToolAnnotations{
+ Title: t("TOOL_CUSTOM_PROPERTIES_WRITE_USER_TITLE", "Set custom properties"),
+ ReadOnlyHint: false,
+ },
+ InputSchema: &jsonschema.Schema{
+ Type: "object",
+ Properties: map[string]*jsonschema.Schema{
+ "level": {
+ Type: "string",
+ Enum: []any{"repository", "organization", "enterprise"},
+ Description: customPropertiesLevelDescription,
+ },
+ "owner": {
+ Type: "string",
+ Description: "Repository owner. Required when level is 'repository'.",
+ },
+ "repo": {
+ Type: "string",
+ Description: "Repository name. Required when level is 'repository'.",
+ },
+ "org": {
+ Type: "string",
+ Description: "Organization name. Required when level is 'organization'.",
+ },
+ "enterprise": {
+ Type: "string",
+ Description: "Enterprise slug. Required when level is 'enterprise'.",
+ },
+ "properties": {
+ Type: "array",
+ Description: "The custom properties to create or update. At the repository level each item assigns a value ('property_name' and 'value'); at the organization and enterprise levels each item defines the schema ('property_name' and 'value_type', plus optional definition fields).",
+ Items: customPropertyItemSchema(),
+ },
+ },
+ Required: []string{"level", "properties"},
+ },
+ },
+ governanceWriteScopeAccess(),
+ func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
+ level, err := RequiredParam[string](args, "level")
+ if err != nil {
+ return utils.NewToolResultError(err.Error()), nil, nil
+ }
+
+ client, err := deps.GetClient(ctx)
+ if err != nil {
+ return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err)
+ }
+
+ switch level {
+ case "repository":
+ return customPropertiesWriteRepository(ctx, client, args)
+ case "organization":
+ return customPropertiesWriteOrganization(ctx, client, args)
+ case "enterprise":
+ return customPropertiesWriteEnterprise(ctx, client, args)
+ default:
+ return utils.NewToolResultError(fmt.Sprintf("unknown level: %q (expected 'repository', 'organization', or 'enterprise')", level)), nil, nil
+ }
+ },
+ )
+}
+
+// customPropertiesWriteRepository handles custom_properties_write calls with level="repository".
+func customPropertiesWriteRepository(ctx context.Context, client *github.Client, args map[string]any) (*mcp.CallToolResult, any, error) {
+ owner, err := RequiredParam[string](args, "owner")
+ if err != nil {
+ return utils.NewToolResultError(err.Error()), nil, nil
+ }
+ repo, err := RequiredParam[string](args, "repo")
+ if err != nil {
+ return utils.NewToolResultError(err.Error()), nil, nil
+ }
+ values, errResult := parseCustomProperties[*github.CustomPropertyValue](args)
+ if errResult != nil {
+ return errResult, nil, nil
+ }
+
+ resp, err := client.Repositories.CreateOrUpdateCustomProperties(ctx, owner, repo, values)
+ if resp != nil {
+ defer func() { _ = resp.Body.Close() }()
+ }
+ if err != nil {
+ return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to update repository custom property values", resp, err), nil, nil
+ }
+
+ return utils.NewToolResultText("Repository custom property values updated successfully"), nil, nil
+}
+
+// customPropertiesWriteOrganization handles custom_properties_write calls with level="organization".
+func customPropertiesWriteOrganization(ctx context.Context, client *github.Client, args map[string]any) (*mcp.CallToolResult, any, error) {
+ org, err := RequiredParam[string](args, "org")
+ if err != nil {
+ return utils.NewToolResultError(err.Error()), nil, nil
+ }
+ properties, errResult := parseCustomProperties[*github.CustomProperty](args)
+ if errResult != nil {
+ return errResult, nil, nil
+ }
+
+ updated, resp, err := client.Organizations.CreateOrUpdateCustomProperties(ctx, org, properties)
+ if resp != nil {
+ defer func() { _ = resp.Body.Close() }()
+ }
+ if err != nil {
+ return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to update organization custom properties", resp, err), nil, nil
+ }
+
+ return MarshalledTextResult(updated), nil, nil
+}
+
+// customPropertiesWriteEnterprise handles custom_properties_write calls with level="enterprise".
+func customPropertiesWriteEnterprise(ctx context.Context, client *github.Client, args map[string]any) (*mcp.CallToolResult, any, error) {
+ enterprise, err := RequiredParam[string](args, "enterprise")
+ if err != nil {
+ return utils.NewToolResultError(err.Error()), nil, nil
+ }
+ properties, errResult := parseCustomProperties[*github.CustomProperty](args)
+ if errResult != nil {
+ return errResult, nil, nil
+ }
+
+ updated, resp, err := client.Enterprise.CreateOrUpdateCustomProperties(ctx, enterprise, properties)
+ if resp != nil {
+ defer func() { _ = resp.Body.Close() }()
+ }
+ if err != nil {
+ return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to update enterprise custom properties", resp, err), nil, nil
+ }
+
+ return MarshalledTextResult(updated), nil, nil
+}
+
+// customPropertyItemSchema describes a single item of the "properties" array
+// for custom_properties_write. It covers both a repository property value and
+// an organization or enterprise property definition; which fields apply
+// depends on the "level" argument.
+func customPropertyItemSchema() *jsonschema.Schema {
+ return &jsonschema.Schema{
+ Type: "object",
+ Properties: map[string]*jsonschema.Schema{
+ "property_name": {
+ Type: "string",
+ Description: "The name of the custom property.",
+ },
+ "value": {
+ Description: "Repository level only: the value to assign. A string, an array of strings, or null to clear the value.",
+ },
+ "value_type": {
+ Type: "string",
+ Enum: []any{"string", "single_select", "multi_select", "true_false", "url"},
+ Description: "Organization and enterprise levels only: the data type of the property. Required when defining a property.",
+ },
+ "required": {
+ Type: "boolean",
+ Description: "Organization and enterprise levels only: whether the property must be set on every repository.",
+ },
+ "default_value": {
+ Description: "Organization and enterprise levels only: the value applied when a repository does not set the property. A string or an array of strings.",
+ },
+ "description": {
+ Type: "string",
+ Description: "Organization and enterprise levels only: a short description of the property.",
+ },
+ "allowed_values": {
+ Type: "array",
+ Description: "Organization and enterprise levels only: the ordered list of allowed values for single_select and multi_select properties.",
+ Items: &jsonschema.Schema{Type: "string"},
+ },
+ "values_editable_by": {
+ Type: "string",
+ Enum: []any{"org_actors", "org_and_repo_actors"},
+ Description: "Organization and enterprise levels only: who can edit the values of the property.",
+ },
+ },
+ Required: []string{"property_name"},
+ }
+}
+
+// parseCustomProperties reads the "properties" array argument and decodes it into
+// the requested go-github type. It returns a non-nil *mcp.CallToolResult
+// describing the problem when the argument is missing or malformed.
+func parseCustomProperties[T any](args map[string]any) ([]T, *mcp.CallToolResult) {
+ raw, ok := args["properties"]
+ if !ok || raw == nil {
+ return nil, utils.NewToolResultError("properties parameter is required")
+ }
+ arr, ok := raw.([]any)
+ if !ok {
+ return nil, utils.NewToolResultError("properties parameter must be an array")
+ }
+
+ encoded, err := json.Marshal(arr)
+ if err != nil {
+ return nil, utils.NewToolResultErrorFromErr("failed to encode properties", err)
+ }
+ var out []T
+ if err := json.Unmarshal(encoded, &out); err != nil {
+ return nil, utils.NewToolResultErrorFromErr("failed to parse properties", err)
+ }
+ return out, nil
+}
diff --git a/pkg/github/custom_properties_test.go b/pkg/github/custom_properties_test.go
new file mode 100644
index 0000000000..505c15402f
--- /dev/null
+++ b/pkg/github/custom_properties_test.go
@@ -0,0 +1,262 @@
+package github
+
+import (
+ "context"
+ "encoding/json"
+ "io"
+ "net/http"
+ "testing"
+
+ "github.com/google/go-github/v89/github"
+ "github.com/google/jsonschema-go/jsonschema"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/github/github-mcp-server/internal/toolsnaps"
+ "github.com/github/github-mcp-server/pkg/translations"
+)
+
+func Test_CustomPropertiesRead(t *testing.T) {
+ toolDef := CustomPropertiesRead(translations.NullTranslationHelper)
+ require.NoError(t, toolsnaps.Test(toolDef.Tool.Name, toolDef.Tool))
+
+ assert.Equal(t, "custom_properties_read", toolDef.Tool.Name)
+ assert.NotEmpty(t, toolDef.Tool.Description)
+ assert.True(t, toolDef.Tool.Annotations.ReadOnlyHint)
+
+ schema, ok := toolDef.Tool.InputSchema.(*jsonschema.Schema)
+ require.True(t, ok, "InputSchema should be *jsonschema.Schema")
+ assert.ElementsMatch(t, schema.Required, []string{"level"})
+
+ t.Run("repository level: returns property values", func(t *testing.T) {
+ mockValues := []*github.CustomPropertyValue{{PropertyName: "environment", Value: "production"}}
+ client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
+ "GET /repos/{owner}/{repo}/properties/values": mockResponse(t, http.StatusOK, mockValues),
+ }))
+ deps := BaseDeps{Client: client}
+ handler := toolDef.Handler(deps)
+ request := createMCPRequest(map[string]any{"level": "repository", "owner": "owner", "repo": "repo"})
+
+ result, err := handler(ContextWithDeps(context.Background(), deps), &request)
+ require.NoError(t, err)
+ require.False(t, result.IsError)
+
+ var returned []*github.CustomPropertyValue
+ require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &returned))
+ require.Len(t, returned, 1)
+ assert.Equal(t, "environment", returned[0].PropertyName)
+ })
+
+ t.Run("repository level: requires owner and repo", func(t *testing.T) {
+ client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}))
+ deps := BaseDeps{Client: client}
+ handler := toolDef.Handler(deps)
+ request := createMCPRequest(map[string]any{"level": "repository", "owner": "owner"})
+
+ result, err := handler(ContextWithDeps(context.Background(), deps), &request)
+ require.NoError(t, err)
+ require.True(t, result.IsError)
+ assert.Contains(t, getErrorResult(t, result).Text, "repo")
+ })
+
+ t.Run("organization level: returns property definitions", func(t *testing.T) {
+ mockProps := []*github.CustomProperty{{PropertyName: github.Ptr("environment"), ValueType: "single_select"}}
+ client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
+ "GET /orgs/{org}/properties/schema": mockResponse(t, http.StatusOK, mockProps),
+ }))
+ deps := BaseDeps{Client: client}
+ handler := toolDef.Handler(deps)
+ request := createMCPRequest(map[string]any{"level": "organization", "org": "octo"})
+
+ result, err := handler(ContextWithDeps(context.Background(), deps), &request)
+ require.NoError(t, err)
+ require.False(t, result.IsError)
+
+ var returned []*github.CustomProperty
+ require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &returned))
+ require.Len(t, returned, 1)
+ assert.Equal(t, "environment", returned[0].GetPropertyName())
+ })
+
+ t.Run("organization level: requires org", func(t *testing.T) {
+ client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}))
+ deps := BaseDeps{Client: client}
+ handler := toolDef.Handler(deps)
+ request := createMCPRequest(map[string]any{"level": "organization"})
+
+ result, err := handler(ContextWithDeps(context.Background(), deps), &request)
+ require.NoError(t, err)
+ require.True(t, result.IsError)
+ assert.Contains(t, getErrorResult(t, result).Text, "org")
+ })
+
+ t.Run("enterprise level: returns property definitions", func(t *testing.T) {
+ mockProps := []*github.CustomProperty{{PropertyName: github.Ptr("compliance"), ValueType: "true_false"}}
+ client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
+ "GET /enterprises/{enterprise}/properties/schema": mockResponse(t, http.StatusOK, mockProps),
+ }))
+ deps := BaseDeps{Client: client}
+ handler := toolDef.Handler(deps)
+ request := createMCPRequest(map[string]any{"level": "enterprise", "enterprise": "acme"})
+
+ result, err := handler(ContextWithDeps(context.Background(), deps), &request)
+ require.NoError(t, err)
+ require.False(t, result.IsError)
+
+ var returned []*github.CustomProperty
+ require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &returned))
+ require.Len(t, returned, 1)
+ assert.Equal(t, "compliance", returned[0].GetPropertyName())
+ })
+
+ t.Run("unknown level returns an error", func(t *testing.T) {
+ client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}))
+ deps := BaseDeps{Client: client}
+ handler := toolDef.Handler(deps)
+ request := createMCPRequest(map[string]any{"level": "team"})
+
+ result, err := handler(ContextWithDeps(context.Background(), deps), &request)
+ require.NoError(t, err)
+ require.True(t, result.IsError)
+ assert.Contains(t, getErrorResult(t, result).Text, "unknown level")
+ })
+}
+
+func Test_CustomPropertiesWrite(t *testing.T) {
+ toolDef := CustomPropertiesWrite(translations.NullTranslationHelper)
+ require.NoError(t, toolsnaps.Test(toolDef.Tool.Name, toolDef.Tool))
+
+ assert.Equal(t, "custom_properties_write", toolDef.Tool.Name)
+ assert.NotEmpty(t, toolDef.Tool.Description)
+ assert.False(t, toolDef.Tool.Annotations.ReadOnlyHint)
+
+ schema, ok := toolDef.Tool.InputSchema.(*jsonschema.Schema)
+ require.True(t, ok, "InputSchema should be *jsonschema.Schema")
+ assert.ElementsMatch(t, schema.Required, []string{"level", "properties"})
+
+ t.Run("repository level: sets property values", func(t *testing.T) {
+ var captured struct {
+ Properties []*github.CustomPropertyValue `json:"properties"`
+ }
+ client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
+ "PATCH /repos/{owner}/{repo}/properties/values": func(w http.ResponseWriter, r *http.Request) {
+ body, _ := io.ReadAll(r.Body)
+ _ = json.Unmarshal(body, &captured)
+ w.WriteHeader(http.StatusNoContent)
+ },
+ }))
+ deps := BaseDeps{Client: client}
+ handler := toolDef.Handler(deps)
+ request := createMCPRequest(map[string]any{
+ "level": "repository",
+ "owner": "owner",
+ "repo": "repo",
+ "properties": []any{
+ map[string]any{"property_name": "environment", "value": "production"},
+ },
+ })
+
+ result, err := handler(ContextWithDeps(context.Background(), deps), &request)
+ require.NoError(t, err)
+ require.False(t, result.IsError)
+ assert.Contains(t, getTextResult(t, result).Text, "updated successfully")
+
+ require.Len(t, captured.Properties, 1)
+ assert.Equal(t, "environment", captured.Properties[0].PropertyName)
+ assert.Equal(t, "production", captured.Properties[0].Value)
+ })
+
+ t.Run("repository level: requires properties", func(t *testing.T) {
+ client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}))
+ deps := BaseDeps{Client: client}
+ handler := toolDef.Handler(deps)
+ request := createMCPRequest(map[string]any{"level": "repository", "owner": "owner", "repo": "repo"})
+
+ result, err := handler(ContextWithDeps(context.Background(), deps), &request)
+ require.NoError(t, err)
+ require.True(t, result.IsError)
+ assert.Contains(t, getErrorResult(t, result).Text, "properties parameter is required")
+ })
+
+ t.Run("organization level: defines property schema", func(t *testing.T) {
+ var captured struct {
+ Properties []*github.CustomProperty `json:"properties"`
+ }
+ client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
+ "PATCH /orgs/{org}/properties/schema": func(w http.ResponseWriter, r *http.Request) {
+ body, _ := io.ReadAll(r.Body)
+ _ = json.Unmarshal(body, &captured)
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte(`[{"property_name":"environment","value_type":"single_select"}]`))
+ },
+ }))
+ deps := BaseDeps{Client: client}
+ handler := toolDef.Handler(deps)
+ request := createMCPRequest(map[string]any{
+ "level": "organization",
+ "org": "octo",
+ "properties": []any{
+ map[string]any{
+ "property_name": "environment",
+ "value_type": "single_select",
+ "required": true,
+ "allowed_values": []any{"production", "staging"},
+ },
+ },
+ })
+
+ result, err := handler(ContextWithDeps(context.Background(), deps), &request)
+ require.NoError(t, err)
+ require.False(t, result.IsError)
+
+ require.Len(t, captured.Properties, 1)
+ assert.Equal(t, "environment", captured.Properties[0].GetPropertyName())
+ assert.Equal(t, github.PropertyValueType("single_select"), captured.Properties[0].ValueType)
+ assert.ElementsMatch(t, []string{"production", "staging"}, captured.Properties[0].AllowedValues)
+ })
+
+ t.Run("enterprise level: defines property schema", func(t *testing.T) {
+ var captured struct {
+ Properties []*github.CustomProperty `json:"properties"`
+ }
+ client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
+ "PATCH /enterprises/{enterprise}/properties/schema": func(w http.ResponseWriter, r *http.Request) {
+ body, _ := io.ReadAll(r.Body)
+ _ = json.Unmarshal(body, &captured)
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte(`[{"property_name":"compliance","value_type":"true_false"}]`))
+ },
+ }))
+ deps := BaseDeps{Client: client}
+ handler := toolDef.Handler(deps)
+ request := createMCPRequest(map[string]any{
+ "level": "enterprise",
+ "enterprise": "acme",
+ "properties": []any{
+ map[string]any{"property_name": "compliance", "value_type": "true_false"},
+ },
+ })
+
+ result, err := handler(ContextWithDeps(context.Background(), deps), &request)
+ require.NoError(t, err)
+ require.False(t, result.IsError)
+
+ require.Len(t, captured.Properties, 1)
+ assert.Equal(t, "compliance", captured.Properties[0].GetPropertyName())
+ })
+
+ t.Run("unknown level returns an error", func(t *testing.T) {
+ client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}))
+ deps := BaseDeps{Client: client}
+ handler := toolDef.Handler(deps)
+ request := createMCPRequest(map[string]any{
+ "level": "team",
+ "properties": []any{map[string]any{"property_name": "x"}},
+ })
+
+ result, err := handler(ContextWithDeps(context.Background(), deps), &request)
+ require.NoError(t, err)
+ require.True(t, result.IsError)
+ assert.Contains(t, getErrorResult(t, result).Text, "unknown level")
+ })
+}
diff --git a/pkg/github/rulesets.go b/pkg/github/rulesets.go
index 96c1ff66e5..aaed5f15e2 100644
--- a/pkg/github/rulesets.go
+++ b/pkg/github/rulesets.go
@@ -26,12 +26,13 @@ const rulesetLevelDescription = "The level at which the ruleset is configured:\n
"- 'organization': A ruleset covering repositories in an organization (requires 'org').\n" +
"- 'enterprise': A ruleset covering repositories across an enterprise (requires 'enterprise')."
-// rulesetReadScopeAccess declares the exhaustive scope challenge policy for
-// repository_ruleset_read. The exact scope challenged depends on the "level"
-// argument: repository reads need "repo", organization reads need "read:org",
-// and enterprise reads need "read:enterprise". A missing or unrecognized
-// level returns no challenge so normal handler validation produces the error.
-func rulesetReadScopeAccess() inventory.ScopeAccess {
+// governanceReadScopeAccess declares the exhaustive scope challenge policy
+// shared by the governance read tools. The exact scope challenged depends on
+// the "level" argument: repository reads need "repo", organization reads need
+// "read:org", and enterprise reads need "read:enterprise". A missing or
+// unrecognized level returns no challenge so normal handler validation
+// produces the error.
+func governanceReadScopeAccess() inventory.ScopeAccess {
return scopes.DynamicChallenge(
[]scopes.Scope{scopes.Repo, scopes.ReadOrg, scopes.ReadEnterprise},
func([]string) bool {
@@ -58,13 +59,13 @@ func rulesetReadScopeAccess() inventory.ScopeAccess {
)
}
-// rulesetWriteScopeAccess declares the exhaustive scope challenge policy for
-// create_repository_ruleset. The exact scope challenged depends on the
-// "level" argument: repository writes need "repo", organization writes need
-// "admin:org", and enterprise writes need "admin:enterprise". A missing or
-// unrecognized level returns no challenge so normal handler validation
+// governanceWriteScopeAccess declares the exhaustive scope challenge policy
+// shared by the governance write tools. The exact scope challenged depends on
+// the "level" argument: repository writes need "repo", organization writes
+// need "admin:org", and enterprise writes need "admin:enterprise". A missing
+// or unrecognized level returns no challenge so normal handler validation
// produces the error.
-func rulesetWriteScopeAccess() inventory.ScopeAccess {
+func governanceWriteScopeAccess() inventory.ScopeAccess {
return scopes.DynamicChallenge(
[]scopes.Scope{scopes.Repo, scopes.AdminOrg, scopes.AdminEnterprise},
func([]string) bool { return true },
@@ -173,7 +174,7 @@ func RepositoryRulesetRead(t translations.TranslationHelperFunc) inventory.Serve
Required: []string{"level", "method"},
}),
},
- rulesetReadScopeAccess(),
+ governanceReadScopeAccess(),
func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
level, err := RequiredParam[string](args, "level")
if err != nil {
@@ -597,7 +598,7 @@ func CreateRepositoryRuleset(t translations.TranslationHelperFunc) inventory.Ser
Required: []string{"level", "name", "enforcement", "rules"},
},
},
- rulesetWriteScopeAccess(),
+ governanceWriteScopeAccess(),
func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
level, err := RequiredParam[string](args, "level")
if err != nil {
diff --git a/pkg/github/rulesets_test.go b/pkg/github/rulesets_test.go
index 46c53825df..c9892c9bf7 100644
--- a/pkg/github/rulesets_test.go
+++ b/pkg/github/rulesets_test.go
@@ -377,7 +377,7 @@ func Test_RepositoryRulesetRead(t *testing.T) {
})
t.Run("mismatched-case level is rejected rather than silently normalized", func(t *testing.T) {
- // The scope challenge in rulesetReadScopeAccess matches "level" with an
+ // The scope challenge in governanceReadScopeAccess matches "level" with an
// exact, case-sensitive comparison. If the handler instead normalized case
// (e.g. via strings.ToLower) before dispatching, a caller could send
// "Organization" to reach the organization-level read while the OAuth
@@ -1028,7 +1028,7 @@ func Test_CreateRepositoryRuleset(t *testing.T) {
})
t.Run("mismatched-case level is rejected rather than silently normalized", func(t *testing.T) {
- // Mirrors the read-tool regression above: rulesetWriteScopeAccess only
+ // Mirrors the read-tool regression above: governanceWriteScopeAccess only
// recognizes an exact, lowercase "organization"/"enterprise" match. If the
// handler normalized case before dispatching, "Organization" would reach
// client.Organizations.CreateRepositoryRuleset while the OAuth middleware
diff --git a/pkg/github/tools.go b/pkg/github/tools.go
index 7cb02a3045..aa8f1b0105 100644
--- a/pkg/github/tools.go
+++ b/pkg/github/tools.go
@@ -75,7 +75,7 @@ var (
}
ToolsetMetadataGovernance = inventory.ToolsetMetadata{
ID: "governance",
- Description: "Repository governance tools for managing rulesets at the repository, organization, and enterprise levels",
+ Description: "Repository governance tools for managing rulesets and custom properties at the repository, organization, and enterprise levels",
Icon: "law",
}
ToolsetMetadataActions = inventory.ToolsetMetadata{
@@ -270,9 +270,11 @@ func AllTools(t translations.TranslationHelperFunc, opts ...ToolOption) []invent
// Organization tools
SearchOrgs(t),
- // Governance tools (rulesets)
+ // Governance tools
RepositoryRulesetRead(t),
CreateRepositoryRuleset(t),
+ CustomPropertiesRead(t),
+ CustomPropertiesWrite(t),
// Pull request tools
PullRequestRead(t),