From 484991c5839d0576b923b53754349481ac319656 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Thu, 27 Aug 2026 16:22:19 +0200 Subject: [PATCH] feat(governance): add custom properties tools with multi-level scope challenge Add custom properties support to the non-default governance toolset, completing the second half of the rulesets + custom properties work requested in #820. Rather than porting the original six single-level tools verbatim, this consolidates them into two level-parameterized tools: - custom_properties_read (level: repository | organization | enterprise) - custom_properties_write (level: repository | organization | enterprise) The `level` argument dispatches to the correct GitHub API. Repository level reads and writes property VALUES (property_name + value), while organization and enterprise levels read and write property DEFINITIONS/schema (value_type, required, allowed_values, default_value, description, values_editable_by). This distinction is documented in the tool and field descriptions. Both tools reuse the shared governanceReadScopeAccess/governanceWriteScopeAccess helpers (renamed from the ruleset-specific names) so rulesets and custom properties present one consistent, exhaustive scope-challenge policy that up-scopes based on the requested level. Co-authored-by: Patrick Knight Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1e886867-a922-419a-b02c-ac643716aea8 --- README.md | 25 +- docs/remote-server.md | 2 +- .../__toolsnaps__/custom_properties_read.snap | 42 ++ .../custom_properties_write.snap | 99 +++++ pkg/github/custom_properties.go | 363 ++++++++++++++++++ pkg/github/custom_properties_test.go | 262 +++++++++++++ pkg/github/rulesets.go | 29 +- pkg/github/rulesets_test.go | 4 +- pkg/github/tools.go | 6 +- 9 files changed, 812 insertions(+), 20 deletions(-) create mode 100644 pkg/github/__toolsnaps__/custom_properties_read.snap create mode 100644 pkg/github/__toolsnaps__/custom_properties_write.snap create mode 100644 pkg/github/custom_properties.go create mode 100644 pkg/github/custom_properties_test.go 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: | 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 | @@ -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 | 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__/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),