diff --git a/README.md b/README.md
index 6ccb9172a0..aaa0f18cfb 100644
--- a/README.md
+++ b/README.md
@@ -562,7 +562,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 |
@@ -850,6 +850,22 @@ 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' (string, optional)
+- **create_or_update_enterprise_custom_properties** - Define enterprise custom properties
+ - **Required OAuth Scopes**: `admin:enterprise`
+ - `enterprise`: Enterprise slug (string, required)
+ - `properties`: The custom property definitions to create or update (object[], required)
+
+- **create_or_update_organization_custom_properties** - Define organization custom properties
+ - **Required OAuth Scopes**: `admin:org`
+ - `org`: Organization name (string, required)
+ - `properties`: The custom property definitions to create or update (object[], required)
+
+- **create_or_update_repository_custom_properties** - Set repository custom property values
+ - **Required OAuth Scopes**: `repo`
+ - `owner`: Repository owner (string, required)
+ - `properties`: The custom property values to assign to the repository (object[], required)
+ - `repo`: Repository name (string, required)
+
- **create_organization_repository_ruleset** - Create organization repository ruleset
- **Required OAuth Scopes**: `admin:org`
- `bypass_actors`: The actors that can bypass the rules in this ruleset (object[], optional)
@@ -871,6 +887,21 @@ 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' (string, optional)
+- **get_enterprise_custom_properties** - Get enterprise custom properties
+ - **Required OAuth Scopes**: `read:enterprise`
+ - **Accepted OAuth Scopes**: `admin:enterprise`, `read:enterprise`
+ - `enterprise`: Enterprise slug (string, required)
+
+- **get_organization_custom_properties** - Get organization custom properties
+ - **Required OAuth Scopes**: `read:org`
+ - **Accepted OAuth Scopes**: `admin:org`, `read:org`, `write:org`
+ - `org`: Organization name (string, required)
+
+- **get_repository_custom_properties** - Get repository custom property values
+ - **Required OAuth Scopes**: `repo`
+ - `owner`: Repository owner (string, required)
+ - `repo`: Repository name (string, required)
+
- **organization_repository_ruleset_read** - Read organization repository rulesets
- **Required OAuth Scopes**: `read:org`
- **Accepted OAuth Scopes**: `admin:org`, `read:org`, `write:org`
diff --git a/docs/remote-server.md b/docs/remote-server.md
index 85721a34bf..7156b96c8f 100644
--- a/docs/remote-server.md
+++ b/docs/remote-server.md
@@ -29,7 +29,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__/create_or_update_enterprise_custom_properties.snap b/pkg/github/__toolsnaps__/create_or_update_enterprise_custom_properties.snap
new file mode 100644
index 0000000000..65c287a78f
--- /dev/null
+++ b/pkg/github/__toolsnaps__/create_or_update_enterprise_custom_properties.snap
@@ -0,0 +1,74 @@
+{
+ "annotations": {
+ "title": "Define enterprise custom properties"
+ },
+ "description": "Create or update custom property definitions (schema) for an enterprise",
+ "inputSchema": {
+ "properties": {
+ "enterprise": {
+ "description": "Enterprise slug",
+ "type": "string"
+ },
+ "properties": {
+ "description": "The custom property definitions to create or update",
+ "items": {
+ "properties": {
+ "allowed_values": {
+ "description": "An ordered list of the allowed values of the property (for single_select and multi_select)",
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
+ "default_value": {
+ "description": "Default value of the property. A string or an array of strings"
+ },
+ "description": {
+ "description": "Short description of the property",
+ "type": "string"
+ },
+ "property_name": {
+ "description": "The name of the custom property",
+ "type": "string"
+ },
+ "required": {
+ "description": "Whether the property is required",
+ "type": "boolean"
+ },
+ "value_type": {
+ "description": "The type of the value for the property",
+ "enum": [
+ "string",
+ "single_select",
+ "multi_select",
+ "true_false",
+ "url"
+ ],
+ "type": "string"
+ },
+ "values_editable_by": {
+ "description": "Who can edit the values of the property",
+ "enum": [
+ "org_actors",
+ "org_and_repo_actors"
+ ],
+ "type": "string"
+ }
+ },
+ "required": [
+ "property_name",
+ "value_type"
+ ],
+ "type": "object"
+ },
+ "type": "array"
+ }
+ },
+ "required": [
+ "enterprise",
+ "properties"
+ ],
+ "type": "object"
+ },
+ "name": "create_or_update_enterprise_custom_properties"
+}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/create_or_update_organization_custom_properties.snap b/pkg/github/__toolsnaps__/create_or_update_organization_custom_properties.snap
new file mode 100644
index 0000000000..5a0a3956ef
--- /dev/null
+++ b/pkg/github/__toolsnaps__/create_or_update_organization_custom_properties.snap
@@ -0,0 +1,74 @@
+{
+ "annotations": {
+ "title": "Define organization custom properties"
+ },
+ "description": "Create or update custom property definitions (schema) for an organization",
+ "inputSchema": {
+ "properties": {
+ "org": {
+ "description": "Organization name",
+ "type": "string"
+ },
+ "properties": {
+ "description": "The custom property definitions to create or update",
+ "items": {
+ "properties": {
+ "allowed_values": {
+ "description": "An ordered list of the allowed values of the property (for single_select and multi_select)",
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
+ "default_value": {
+ "description": "Default value of the property. A string or an array of strings"
+ },
+ "description": {
+ "description": "Short description of the property",
+ "type": "string"
+ },
+ "property_name": {
+ "description": "The name of the custom property",
+ "type": "string"
+ },
+ "required": {
+ "description": "Whether the property is required",
+ "type": "boolean"
+ },
+ "value_type": {
+ "description": "The type of the value for the property",
+ "enum": [
+ "string",
+ "single_select",
+ "multi_select",
+ "true_false",
+ "url"
+ ],
+ "type": "string"
+ },
+ "values_editable_by": {
+ "description": "Who can edit the values of the property",
+ "enum": [
+ "org_actors",
+ "org_and_repo_actors"
+ ],
+ "type": "string"
+ }
+ },
+ "required": [
+ "property_name",
+ "value_type"
+ ],
+ "type": "object"
+ },
+ "type": "array"
+ }
+ },
+ "required": [
+ "org",
+ "properties"
+ ],
+ "type": "object"
+ },
+ "name": "create_or_update_organization_custom_properties"
+}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/create_or_update_repository_custom_properties.snap b/pkg/github/__toolsnaps__/create_or_update_repository_custom_properties.snap
new file mode 100644
index 0000000000..8867e94875
--- /dev/null
+++ b/pkg/github/__toolsnaps__/create_or_update_repository_custom_properties.snap
@@ -0,0 +1,44 @@
+{
+ "annotations": {
+ "title": "Set repository custom property values"
+ },
+ "description": "Create or update the custom property values assigned to a repository. The properties must already be defined at the organization level",
+ "inputSchema": {
+ "properties": {
+ "owner": {
+ "description": "Repository owner",
+ "type": "string"
+ },
+ "properties": {
+ "description": "The custom property values to assign to the repository",
+ "items": {
+ "properties": {
+ "property_name": {
+ "description": "The name of the custom property",
+ "type": "string"
+ },
+ "value": {
+ "description": "The value to assign. A string, an array of strings, or null to clear the value"
+ }
+ },
+ "required": [
+ "property_name"
+ ],
+ "type": "object"
+ },
+ "type": "array"
+ },
+ "repo": {
+ "description": "Repository name",
+ "type": "string"
+ }
+ },
+ "required": [
+ "owner",
+ "repo",
+ "properties"
+ ],
+ "type": "object"
+ },
+ "name": "create_or_update_repository_custom_properties"
+}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/get_enterprise_custom_properties.snap b/pkg/github/__toolsnaps__/get_enterprise_custom_properties.snap
new file mode 100644
index 0000000000..f65ee69ff2
--- /dev/null
+++ b/pkg/github/__toolsnaps__/get_enterprise_custom_properties.snap
@@ -0,0 +1,20 @@
+{
+ "annotations": {
+ "readOnlyHint": true,
+ "title": "Get enterprise custom properties"
+ },
+ "description": "Get all custom property definitions (schema) for an enterprise",
+ "inputSchema": {
+ "properties": {
+ "enterprise": {
+ "description": "Enterprise slug",
+ "type": "string"
+ }
+ },
+ "required": [
+ "enterprise"
+ ],
+ "type": "object"
+ },
+ "name": "get_enterprise_custom_properties"
+}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/get_organization_custom_properties.snap b/pkg/github/__toolsnaps__/get_organization_custom_properties.snap
new file mode 100644
index 0000000000..8210ea287d
--- /dev/null
+++ b/pkg/github/__toolsnaps__/get_organization_custom_properties.snap
@@ -0,0 +1,20 @@
+{
+ "annotations": {
+ "readOnlyHint": true,
+ "title": "Get organization custom properties"
+ },
+ "description": "Get all custom property definitions (schema) for an organization",
+ "inputSchema": {
+ "properties": {
+ "org": {
+ "description": "Organization name",
+ "type": "string"
+ }
+ },
+ "required": [
+ "org"
+ ],
+ "type": "object"
+ },
+ "name": "get_organization_custom_properties"
+}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/get_repository_custom_properties.snap b/pkg/github/__toolsnaps__/get_repository_custom_properties.snap
new file mode 100644
index 0000000000..2116bd94a1
--- /dev/null
+++ b/pkg/github/__toolsnaps__/get_repository_custom_properties.snap
@@ -0,0 +1,25 @@
+{
+ "annotations": {
+ "readOnlyHint": true,
+ "title": "Get repository custom property values"
+ },
+ "description": "Get the custom property values that are assigned to a repository",
+ "inputSchema": {
+ "properties": {
+ "owner": {
+ "description": "Repository owner",
+ "type": "string"
+ },
+ "repo": {
+ "description": "Repository name",
+ "type": "string"
+ }
+ },
+ "required": [
+ "owner",
+ "repo"
+ ],
+ "type": "object"
+ },
+ "name": "get_repository_custom_properties"
+}
\ 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..3f2ed15cb8
--- /dev/null
+++ b/pkg/github/custom_properties.go
@@ -0,0 +1,412 @@
+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/scopes"
+ "github.com/github/github-mcp-server/pkg/translations"
+ "github.com/github/github-mcp-server/pkg/utils"
+ "github.com/google/go-github/v87/github"
+ "github.com/google/jsonschema-go/jsonschema"
+ "github.com/modelcontextprotocol/go-sdk/mcp"
+)
+
+// GetRepositoryCustomProperties creates a tool to get the custom property values assigned to a repository.
+func GetRepositoryCustomProperties(t translations.TranslationHelperFunc) inventory.ServerTool {
+ return NewTool(
+ ToolsetMetadataGovernance,
+ mcp.Tool{
+ Name: "get_repository_custom_properties",
+ Description: t("TOOL_GET_REPOSITORY_CUSTOM_PROPERTIES_DESCRIPTION", "Get the custom property values that are assigned to a repository"),
+ Annotations: &mcp.ToolAnnotations{
+ Title: t("TOOL_GET_REPOSITORY_CUSTOM_PROPERTIES_USER_TITLE", "Get repository custom property values"),
+ ReadOnlyHint: true,
+ },
+ InputSchema: &jsonschema.Schema{
+ Type: "object",
+ Properties: map[string]*jsonschema.Schema{
+ "owner": {
+ Type: "string",
+ Description: "Repository owner",
+ },
+ "repo": {
+ Type: "string",
+ Description: "Repository name",
+ },
+ },
+ Required: []string{"owner", "repo"},
+ },
+ },
+ []scopes.Scope{scopes.Repo},
+ func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
+ owner, err := RequiredParam[string](args, "owner")
+ if err != nil {
+ return utils.NewToolResultError(err.Error()), nil, nil
+ }
+ repo, err := RequiredParam[string](args, "repo")
+ if err != nil {
+ return utils.NewToolResultError(err.Error()), nil, nil
+ }
+
+ client, err := deps.GetClient(ctx)
+ if err != nil {
+ return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err)
+ }
+
+ properties, resp, err := client.Repositories.GetAllCustomPropertyValues(ctx, owner, repo)
+ if err != nil {
+ return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get repository custom properties", resp, err), nil, nil
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ return MarshalledTextResult(properties), nil, nil
+ },
+ )
+}
+
+// CreateOrUpdateRepositoryCustomProperties creates a tool to set custom property values on a repository.
+func CreateOrUpdateRepositoryCustomProperties(t translations.TranslationHelperFunc) inventory.ServerTool {
+ return NewTool(
+ ToolsetMetadataGovernance,
+ mcp.Tool{
+ Name: "create_or_update_repository_custom_properties",
+ Description: t("TOOL_CREATE_OR_UPDATE_REPOSITORY_CUSTOM_PROPERTIES_DESCRIPTION", "Create or update the custom property values assigned to a repository. The properties must already be defined at the organization level"),
+ Annotations: &mcp.ToolAnnotations{
+ Title: t("TOOL_CREATE_OR_UPDATE_REPOSITORY_CUSTOM_PROPERTIES_USER_TITLE", "Set repository custom property values"),
+ ReadOnlyHint: false,
+ },
+ InputSchema: &jsonschema.Schema{
+ Type: "object",
+ Properties: map[string]*jsonschema.Schema{
+ "owner": {
+ Type: "string",
+ Description: "Repository owner",
+ },
+ "repo": {
+ Type: "string",
+ Description: "Repository name",
+ },
+ "properties": {
+ Type: "array",
+ Description: "The custom property values to assign to the repository",
+ Items: customPropertyValueItemSchema(),
+ },
+ },
+ Required: []string{"owner", "repo", "properties"},
+ },
+ },
+ []scopes.Scope{scopes.Repo},
+ func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
+ owner, err := RequiredParam[string](args, "owner")
+ if err != nil {
+ return utils.NewToolResultError(err.Error()), nil, nil
+ }
+ repo, err := RequiredParam[string](args, "repo")
+ if err != nil {
+ return utils.NewToolResultError(err.Error()), nil, nil
+ }
+ values, errResult := parseCustomProperties[*github.CustomPropertyValue](args)
+ if errResult != nil {
+ return errResult, nil, nil
+ }
+
+ client, err := deps.GetClient(ctx)
+ if err != nil {
+ return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err)
+ }
+
+ resp, err := client.Repositories.CreateOrUpdateCustomProperties(ctx, owner, repo, values)
+ if err != nil {
+ return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to update repository custom properties", resp, err), nil, nil
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ return utils.NewToolResultText("Repository custom property values updated successfully"), nil, nil
+ },
+ )
+}
+
+// GetOrganizationCustomProperties creates a tool to get the custom property definitions for an organization.
+func GetOrganizationCustomProperties(t translations.TranslationHelperFunc) inventory.ServerTool {
+ return NewTool(
+ ToolsetMetadataGovernance,
+ mcp.Tool{
+ Name: "get_organization_custom_properties",
+ Description: t("TOOL_GET_ORGANIZATION_CUSTOM_PROPERTIES_DESCRIPTION", "Get all custom property definitions (schema) for an organization"),
+ Annotations: &mcp.ToolAnnotations{
+ Title: t("TOOL_GET_ORGANIZATION_CUSTOM_PROPERTIES_USER_TITLE", "Get organization custom properties"),
+ ReadOnlyHint: true,
+ },
+ InputSchema: &jsonschema.Schema{
+ Type: "object",
+ Properties: map[string]*jsonschema.Schema{
+ "org": {
+ Type: "string",
+ Description: "Organization name",
+ },
+ },
+ Required: []string{"org"},
+ },
+ },
+ []scopes.Scope{scopes.ReadOrg},
+ func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
+ org, err := RequiredParam[string](args, "org")
+ 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)
+ }
+
+ properties, resp, err := client.Organizations.GetAllCustomProperties(ctx, org)
+ if err != nil {
+ return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get organization custom properties", resp, err), nil, nil
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ return MarshalledTextResult(properties), nil, nil
+ },
+ )
+}
+
+// CreateOrUpdateOrganizationCustomProperties creates a tool to define custom properties for an organization.
+func CreateOrUpdateOrganizationCustomProperties(t translations.TranslationHelperFunc) inventory.ServerTool {
+ return NewTool(
+ ToolsetMetadataGovernance,
+ mcp.Tool{
+ Name: "create_or_update_organization_custom_properties",
+ Description: t("TOOL_CREATE_OR_UPDATE_ORGANIZATION_CUSTOM_PROPERTIES_DESCRIPTION", "Create or update custom property definitions (schema) for an organization"),
+ Annotations: &mcp.ToolAnnotations{
+ Title: t("TOOL_CREATE_OR_UPDATE_ORGANIZATION_CUSTOM_PROPERTIES_USER_TITLE", "Define organization custom properties"),
+ ReadOnlyHint: false,
+ },
+ InputSchema: &jsonschema.Schema{
+ Type: "object",
+ Properties: map[string]*jsonschema.Schema{
+ "org": {
+ Type: "string",
+ Description: "Organization name",
+ },
+ "properties": {
+ Type: "array",
+ Description: "The custom property definitions to create or update",
+ Items: customPropertyDefinitionItemSchema(),
+ },
+ },
+ Required: []string{"org", "properties"},
+ },
+ },
+ []scopes.Scope{scopes.AdminOrg},
+ func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, 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
+ }
+
+ client, err := deps.GetClient(ctx)
+ if err != nil {
+ return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err)
+ }
+
+ updated, resp, err := client.Organizations.CreateOrUpdateCustomProperties(ctx, org, properties)
+ if err != nil {
+ return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to update organization custom properties", resp, err), nil, nil
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ return MarshalledTextResult(updated), nil, nil
+ },
+ )
+}
+
+// GetEnterpriseCustomProperties creates a tool to get the custom property definitions for an enterprise.
+func GetEnterpriseCustomProperties(t translations.TranslationHelperFunc) inventory.ServerTool {
+ return NewTool(
+ ToolsetMetadataGovernance,
+ mcp.Tool{
+ Name: "get_enterprise_custom_properties",
+ Description: t("TOOL_GET_ENTERPRISE_CUSTOM_PROPERTIES_DESCRIPTION", "Get all custom property definitions (schema) for an enterprise"),
+ Annotations: &mcp.ToolAnnotations{
+ Title: t("TOOL_GET_ENTERPRISE_CUSTOM_PROPERTIES_USER_TITLE", "Get enterprise custom properties"),
+ ReadOnlyHint: true,
+ },
+ InputSchema: &jsonschema.Schema{
+ Type: "object",
+ Properties: map[string]*jsonschema.Schema{
+ "enterprise": {
+ Type: "string",
+ Description: "Enterprise slug",
+ },
+ },
+ Required: []string{"enterprise"},
+ },
+ },
+ []scopes.Scope{scopes.ReadEnterprise},
+ func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
+ enterprise, err := RequiredParam[string](args, "enterprise")
+ 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)
+ }
+
+ properties, resp, err := client.Enterprise.GetAllCustomProperties(ctx, enterprise)
+ if err != nil {
+ return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get enterprise custom properties", resp, err), nil, nil
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ return MarshalledTextResult(properties), nil, nil
+ },
+ )
+}
+
+// CreateOrUpdateEnterpriseCustomProperties creates a tool to define custom properties for an enterprise.
+func CreateOrUpdateEnterpriseCustomProperties(t translations.TranslationHelperFunc) inventory.ServerTool {
+ return NewTool(
+ ToolsetMetadataGovernance,
+ mcp.Tool{
+ Name: "create_or_update_enterprise_custom_properties",
+ Description: t("TOOL_CREATE_OR_UPDATE_ENTERPRISE_CUSTOM_PROPERTIES_DESCRIPTION", "Create or update custom property definitions (schema) for an enterprise"),
+ Annotations: &mcp.ToolAnnotations{
+ Title: t("TOOL_CREATE_OR_UPDATE_ENTERPRISE_CUSTOM_PROPERTIES_USER_TITLE", "Define enterprise custom properties"),
+ ReadOnlyHint: false,
+ },
+ InputSchema: &jsonschema.Schema{
+ Type: "object",
+ Properties: map[string]*jsonschema.Schema{
+ "enterprise": {
+ Type: "string",
+ Description: "Enterprise slug",
+ },
+ "properties": {
+ Type: "array",
+ Description: "The custom property definitions to create or update",
+ Items: customPropertyDefinitionItemSchema(),
+ },
+ },
+ Required: []string{"enterprise", "properties"},
+ },
+ },
+ []scopes.Scope{scopes.AdminEnterprise},
+ func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, 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
+ }
+
+ client, err := deps.GetClient(ctx)
+ if err != nil {
+ return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err)
+ }
+
+ updated, resp, err := client.Enterprise.CreateOrUpdateCustomProperties(ctx, enterprise, properties)
+ if err != nil {
+ return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to update enterprise custom properties", resp, err), nil, nil
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ return MarshalledTextResult(updated), nil, nil
+ },
+ )
+}
+
+// customPropertyValueItemSchema describes a single custom property value assigned
+// to a repository.
+func customPropertyValueItemSchema() *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: "The value to assign. A string, an array of strings, or null to clear the value",
+ },
+ },
+ Required: []string{"property_name"},
+ }
+}
+
+// customPropertyDefinitionItemSchema describes a single custom property definition
+// in an organization or enterprise schema.
+func customPropertyDefinitionItemSchema() *jsonschema.Schema {
+ return &jsonschema.Schema{
+ Type: "object",
+ Properties: map[string]*jsonschema.Schema{
+ "property_name": {
+ Type: "string",
+ Description: "The name of the custom property",
+ },
+ "value_type": {
+ Type: "string",
+ Enum: []any{"string", "single_select", "multi_select", "true_false", "url"},
+ Description: "The type of the value for the property",
+ },
+ "required": {
+ Type: "boolean",
+ Description: "Whether the property is required",
+ },
+ "default_value": {
+ Description: "Default value of the property. A string or an array of strings",
+ },
+ "description": {
+ Type: "string",
+ Description: "Short description of the property",
+ },
+ "allowed_values": {
+ Type: "array",
+ Description: "An ordered list of the allowed values of the property (for single_select and multi_select)",
+ Items: &jsonschema.Schema{Type: "string"},
+ },
+ "values_editable_by": {
+ Type: "string",
+ Enum: []any{"org_actors", "org_and_repo_actors"},
+ Description: "Who can edit the values of the property",
+ },
+ },
+ Required: []string{"property_name", "value_type"},
+ }
+}
+
+// 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..2189697d4a
--- /dev/null
+++ b/pkg/github/custom_properties_test.go
@@ -0,0 +1,242 @@
+package github
+
+import (
+ "context"
+ "encoding/json"
+ "io"
+ "net/http"
+ "testing"
+
+ "github.com/google/go-github/v87/github"
+ "github.com/google/jsonschema-go/jsonschema"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/github/github-mcp-server/internal/toolsnaps"
+ "github.com/github/github-mcp-server/pkg/translations"
+)
+
+func Test_GetRepositoryCustomProperties(t *testing.T) {
+ toolDef := GetRepositoryCustomProperties(translations.NullTranslationHelper)
+ require.NoError(t, toolsnaps.Test(toolDef.Tool.Name, toolDef.Tool))
+
+ assert.Equal(t, "get_repository_custom_properties", toolDef.Tool.Name)
+ assert.True(t, toolDef.Tool.Annotations.ReadOnlyHint)
+
+ schema, ok := toolDef.Tool.InputSchema.(*jsonschema.Schema)
+ require.True(t, ok)
+ assert.ElementsMatch(t, schema.Required, []string{"owner", "repo"})
+
+ 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{"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)
+}
+
+func Test_CreateOrUpdateRepositoryCustomProperties(t *testing.T) {
+ toolDef := CreateOrUpdateRepositoryCustomProperties(translations.NullTranslationHelper)
+ require.NoError(t, toolsnaps.Test(toolDef.Tool.Name, toolDef.Tool))
+
+ assert.Equal(t, "create_or_update_repository_custom_properties", toolDef.Tool.Name)
+ assert.False(t, toolDef.Tool.Annotations.ReadOnlyHint)
+
+ schema, ok := toolDef.Tool.InputSchema.(*jsonschema.Schema)
+ require.True(t, ok)
+ assert.ElementsMatch(t, schema.Required, []string{"owner", "repo", "properties"})
+
+ 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{
+ "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)
+
+ require.Len(t, captured.Properties, 1)
+ assert.Equal(t, "environment", captured.Properties[0].PropertyName)
+ assert.Equal(t, "production", captured.Properties[0].Value)
+}
+
+func Test_CreateOrUpdateRepositoryCustomProperties_MissingProperties(t *testing.T) {
+ toolDef := CreateOrUpdateRepositoryCustomProperties(translations.NullTranslationHelper)
+ client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}))
+ deps := BaseDeps{Client: client}
+ handler := toolDef.Handler(deps)
+ request := createMCPRequest(map[string]any{"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")
+}
+
+func Test_GetOrganizationCustomProperties(t *testing.T) {
+ toolDef := GetOrganizationCustomProperties(translations.NullTranslationHelper)
+ require.NoError(t, toolsnaps.Test(toolDef.Tool.Name, toolDef.Tool))
+
+ assert.Equal(t, "get_organization_custom_properties", toolDef.Tool.Name)
+ assert.True(t, toolDef.Tool.Annotations.ReadOnlyHint)
+
+ schema, ok := toolDef.Tool.InputSchema.(*jsonschema.Schema)
+ require.True(t, ok)
+ assert.ElementsMatch(t, schema.Required, []string{"org"})
+
+ 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{"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())
+}
+
+func Test_CreateOrUpdateOrganizationCustomProperties(t *testing.T) {
+ toolDef := CreateOrUpdateOrganizationCustomProperties(translations.NullTranslationHelper)
+ require.NoError(t, toolsnaps.Test(toolDef.Tool.Name, toolDef.Tool))
+
+ assert.Equal(t, "create_or_update_organization_custom_properties", toolDef.Tool.Name)
+ assert.False(t, toolDef.Tool.Annotations.ReadOnlyHint)
+
+ schema, ok := toolDef.Tool.InputSchema.(*jsonschema.Schema)
+ require.True(t, ok)
+ assert.ElementsMatch(t, schema.Required, []string{"org", "properties"})
+
+ 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{
+ "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)
+}
+
+func Test_GetEnterpriseCustomProperties(t *testing.T) {
+ toolDef := GetEnterpriseCustomProperties(translations.NullTranslationHelper)
+ require.NoError(t, toolsnaps.Test(toolDef.Tool.Name, toolDef.Tool))
+
+ assert.Equal(t, "get_enterprise_custom_properties", toolDef.Tool.Name)
+ assert.True(t, toolDef.Tool.Annotations.ReadOnlyHint)
+
+ schema, ok := toolDef.Tool.InputSchema.(*jsonschema.Schema)
+ require.True(t, ok)
+ assert.ElementsMatch(t, schema.Required, []string{"enterprise"})
+
+ 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{"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())
+}
+
+func Test_CreateOrUpdateEnterpriseCustomProperties(t *testing.T) {
+ toolDef := CreateOrUpdateEnterpriseCustomProperties(translations.NullTranslationHelper)
+ require.NoError(t, toolsnaps.Test(toolDef.Tool.Name, toolDef.Tool))
+
+ assert.Equal(t, "create_or_update_enterprise_custom_properties", toolDef.Tool.Name)
+ assert.False(t, toolDef.Tool.Annotations.ReadOnlyHint)
+
+ schema, ok := toolDef.Tool.InputSchema.(*jsonschema.Schema)
+ require.True(t, ok)
+ assert.ElementsMatch(t, schema.Required, []string{"enterprise", "properties"})
+
+ 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{
+ "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())
+}
diff --git a/pkg/github/tools.go b/pkg/github/tools.go
index c33485e56f..b77e1a303c 100644
--- a/pkg/github/tools.go
+++ b/pkg/github/tools.go
@@ -74,7 +74,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{
@@ -228,12 +228,18 @@ func AllTools(t translations.TranslationHelperFunc) []inventory.ServerTool {
// Organization tools
SearchOrgs(t),
- // Governance tools (rulesets)
+ // Governance tools (rulesets & custom properties)
RepositoryRulesetRead(t),
OrganizationRepositoryRulesetRead(t),
CreateRepositoryRuleset(t),
CreateOrganizationRepositoryRuleset(t),
CreateEnterpriseRepositoryRuleset(t),
+ GetRepositoryCustomProperties(t),
+ CreateOrUpdateRepositoryCustomProperties(t),
+ GetOrganizationCustomProperties(t),
+ CreateOrUpdateOrganizationCustomProperties(t),
+ GetEnterpriseCustomProperties(t),
+ CreateOrUpdateEnterpriseCustomProperties(t),
// Pull request tools
PullRequestRead(t),