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: | comment-discussion | `discussions` | GitHub Discussions related tools | | logo-gist | `gists` | GitHub Gist related tools | | git-branch | `git` | GitHub Git API related tools for low-level Git operations | -| law | `governance` | Repository governance tools for managing rulesets at the repository, organization, and enterprise levels | +| law | `governance` | Repository governance tools for managing rulesets and custom properties at the repository, organization, and enterprise levels | | issue-opened | `issues` | GitHub Issues related tools | | tag | `labels` | GitHub Labels related tools | | bell | `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 | comment-discussion
`discussions` | GitHub Discussions related tools | https://api.githubcopilot.com/mcp/x/discussions | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-discussions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdiscussions%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/discussions/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-discussions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdiscussions%2Freadonly%22%7D) | | logo-gist
`gists` | GitHub Gist related tools | https://api.githubcopilot.com/mcp/x/gists | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-gists&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgists%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/gists/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-gists&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgists%2Freadonly%22%7D) | | git-branch
`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) | -| law
`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) | +| law
`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) | | issue-opened
`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) | | tag
`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) | | bell
`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),