diff --git a/README.md b/README.md
index f7c41e8060..05700b7c6a 100644
--- a/README.md
+++ b/README.md
@@ -779,6 +779,13 @@ For negotiated supported protocol versions `2026-07-28` or newer, the consolidat
Copilot Automations
+- **create_automation_tag** - Create automation tag
+ - **OAuth Challenge Scopes**: `repo`
+ - `description`: Description of the new tag. Omit for an empty description. (string, optional)
+ - `name`: Display name for the new tag (string, required)
+ - `owner`: Repository owner (string, required)
+ - `repo`: Repository name (string, required)
+
- **get_automation_tag** - Get automation tag
- **OAuth Challenge Scopes**: `repo`
- `owner`: Repository owner (string, required)
diff --git a/pkg/github/__toolsnaps__/create_automation_tag.snap b/pkg/github/__toolsnaps__/create_automation_tag.snap
new file mode 100644
index 0000000000..a34fd43a63
--- /dev/null
+++ b/pkg/github/__toolsnaps__/create_automation_tag.snap
@@ -0,0 +1,39 @@
+{
+ "annotations": {
+ "destructiveHint": false,
+ "idempotentHint": false,
+ "openWorldHint": true,
+ "readOnlyHint": false,
+ "title": "Create automation tag"
+ },
+ "description": "Create an automation tag in a repository. Tags organize automations; they do not execute them. Each call, including a retry, creates a new tag, even when another tag has the same name.",
+ "inputSchema": {
+ "properties": {
+ "description": {
+ "description": "Description of the new tag. Omit for an empty description.",
+ "type": "string"
+ },
+ "name": {
+ "description": "Display name for the new tag",
+ "type": "string"
+ },
+ "owner": {
+ "description": "Repository owner",
+ "minLength": 1,
+ "type": "string"
+ },
+ "repo": {
+ "description": "Repository name",
+ "minLength": 1,
+ "type": "string"
+ }
+ },
+ "required": [
+ "owner",
+ "repo",
+ "name"
+ ],
+ "type": "object"
+ },
+ "name": "create_automation_tag"
+}
\ No newline at end of file
diff --git a/pkg/github/automation_tags.go b/pkg/github/automation_tags.go
index 1e3c382088..1035296a6b 100644
--- a/pkg/github/automation_tags.go
+++ b/pkg/github/automation_tags.go
@@ -2,6 +2,7 @@ package github
import (
"context"
+ "crypto/rand"
"fmt"
"net/http"
"net/url"
@@ -182,6 +183,82 @@ func GetAutomationTag(t translations.TranslationHelperFunc) inventory.ServerTool
)
}
+type CreateAutomationTagInput struct {
+ Owner string `json:"owner"`
+ Repo string `json:"repo"`
+ Name string `json:"name"`
+ Description string `json:"description,omitempty"`
+}
+
+func CreateAutomationTag(t translations.TranslationHelperFunc) inventory.ServerTool {
+ return NewTool[CreateAutomationTagInput, *AutomationTag](
+ ToolsetMetadataCopilotAutomations,
+ mcp.Tool{
+ Name: "create_automation_tag",
+ Description: t("TOOL_CREATE_AUTOMATION_TAG_DESCRIPTION", "Create an automation tag in a repository. Tags organize automations; they do not execute them. Each call, including a retry, creates a new tag, even when another tag has the same name."),
+ Annotations: &mcp.ToolAnnotations{
+ Title: t("TOOL_CREATE_AUTOMATION_TAG_USER_TITLE", "Create automation tag"),
+ ReadOnlyHint: false,
+ DestructiveHint: new(false),
+ OpenWorldHint: new(true),
+ },
+ InputSchema: &jsonschema.Schema{
+ Type: "object",
+ Properties: map[string]*jsonschema.Schema{
+ "owner": {Type: "string", Description: "Repository owner", MinLength: new(1)},
+ "repo": {Type: "string", Description: "Repository name", MinLength: new(1)},
+ "name": {Type: "string", Description: "Display name for the new tag"},
+ "description": {Type: "string", Description: "Description of the new tag. Omit for an empty description."},
+ },
+ Required: []string{"owner", "repo", "name"},
+ },
+ },
+ scopes.RequireAll(scopes.Repo),
+ func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input CreateAutomationTagInput) (*mcp.CallToolResult, *AutomationTag, error) {
+ if input.Owner == "" {
+ return utils.NewToolResultError("missing required parameter: owner"), nil, nil
+ }
+ if input.Repo == "" {
+ return utils.NewToolResultError("missing required parameter: repo"), nil, nil
+ }
+ if input.Owner == "." {
+ return utils.NewToolResultError(`owner must not be "."`), nil, nil
+ }
+ if input.Repo == "." {
+ return utils.NewToolResultError(`repo must not be "."`), nil, nil
+ }
+ client, err := deps.GetClient(ctx)
+ if err != nil {
+ return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err)
+ }
+ // A reused key returns the tag it first created without comparing name or
+ // description, so each call sends a new key.
+ body := map[string]any{"name": input.Name, "idempotency_key": rand.Text()}
+ if input.Description != "" {
+ body["description"] = input.Description
+ }
+ apiURL := fmt.Sprintf("agents/repos/%s/%s/automation-tags", url.PathEscape(input.Owner), url.PathEscape(input.Repo))
+ req, err := client.NewRequest(ctx, http.MethodPost, apiURL, body)
+ if err != nil {
+ return utils.NewToolResultErrorFromErr("failed to create automation tag request", err), nil, nil
+ }
+ var output AutomationTag
+ resp, err := client.Do(req, &output)
+ if resp != nil {
+ defer func() { _ = resp.Body.Close() }()
+ }
+ if err != nil {
+ return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to create automation tag", resp, err), nil, nil
+ }
+ if resp.StatusCode != http.StatusCreated {
+ return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to create automation tag", resp, nil), nil, nil
+ }
+ sanitizeAutomationTagNameAndDescription(&output)
+ return MarshalledTextResult(&output), &output, nil
+ },
+ )
+}
+
func sanitizeAutomationTagNameAndDescription(tag *AutomationTag) {
tag.Name = sanitize.PlainText(tag.Name)
tag.Description = sanitize.Content(tag.Description)
diff --git a/pkg/github/automation_tags_test.go b/pkg/github/automation_tags_test.go
index 8a73da543e..ee766b2cc1 100644
--- a/pkg/github/automation_tags_test.go
+++ b/pkg/github/automation_tags_test.go
@@ -413,3 +413,216 @@ func Test_GetAutomationTag(t *testing.T) {
})
}
}
+
+func Test_CreateAutomationTag(t *testing.T) {
+ toolDef := CreateAutomationTag(translations.NullTranslationHelper)
+ require.NoError(t, toolsnaps.Test(toolDef.Tool.Name, toolDef.Tool))
+ assert.False(t, toolDef.Tool.Annotations.ReadOnlyHint)
+
+ t.Run("requires owner and repo", func(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ args map[string]any
+ wantError string
+ }{
+ {"missing owner", map[string]any{"repo": "repo", "name": "Release"}, "owner"},
+ {"empty owner", map[string]any{"owner": "", "repo": "repo", "name": "Release"}, "owner"},
+ {"missing repo", map[string]any{"owner": "owner", "name": "Release"}, "repo"},
+ {"empty repo", map[string]any{"owner": "owner", "repo": "", "name": "Release"}, "repo"},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ calls := 0
+ client := mustNewGHClient(t, MockHTTPClientWithHandler(func(w http.ResponseWriter, _ *http.Request) {
+ calls++
+ w.WriteHeader(http.StatusCreated)
+ _, err := fmt.Fprint(w, `{}`)
+ require.NoError(t, err)
+ }))
+ deps := BaseDeps{Client: client}
+ handler := toolDef.Handler(deps)
+ request := createMCPRequest(tc.args)
+ result, err := handler(ContextWithDeps(context.Background(), deps), &request)
+ require.NoError(t, err)
+ assert.Zero(t, calls)
+ require.True(t, result.IsError)
+ assert.Contains(t, getErrorResult(t, result).Text, tc.wantError)
+ })
+ }
+ })
+
+ t.Run("discovery", func(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ toolsets []string
+ scopes []string
+ readOnly bool
+ want bool
+ }{
+ {"default", []string{"default"}, []string{"repo"}, false, false},
+ {"opt in", []string{"copilot_automations"}, []string{"repo"}, false, true},
+ {"opt in read only", []string{"copilot_automations"}, []string{"repo"}, true, false},
+ {"missing scope", []string{"copilot_automations"}, []string{}, false, false},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ inv, err := NewInventory(translations.NullTranslationHelper).
+ WithToolsets(tc.toolsets).WithReadOnly(tc.readOnly).
+ WithFilter(CreateToolScopeFilter(tc.scopes)).Build()
+ require.NoError(t, err)
+ require.Empty(t, inv.UnrecognizedToolsets())
+ var found bool
+ for _, tool := range inv.AvailableTools(context.Background()) {
+ found = found || tool.Tool.Name == "create_automation_tag"
+ }
+ assert.Equal(t, tc.want, found)
+ })
+ }
+ })
+
+ seen := make(map[string]bool)
+ for _, protocol := range []string{inventory.ProtocolVersionMultiRoundTrip, "2025-11-25"} {
+ t.Run(protocol, func(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ args map[string]any
+ status int
+ body string
+ escapedPath string
+ wantRequestBody string
+ want string
+ wantError string
+ }{
+ {
+ name: "internal and additive fields",
+ status: http.StatusCreated,
+ body: `{"id":"tag-1","name":"Release","description":"","version":"opaque-version","created_at":"2026-10-01T00:00:00Z","updated_at":"2026-10-01T00:00:00Z","can_delete":true,"deletion_pending":false,"has_known_unavailable_members":false,"future_field":true}`,
+ want: `{"id":"tag-1","name":"Release","description":"","version":"opaque-version","created_at":"2026-10-01T00:00:00Z","updated_at":"2026-10-01T00:00:00Z"}`,
+ },
+ {
+ name: "description",
+ args: map[string]any{"owner": "owner", "repo": "repo", "name": "Release", "description": "Release automations"},
+ wantRequestBody: `{"name":"Release","description":"Release automations"}`,
+ status: http.StatusCreated,
+ body: `{"id":"tag-1","name":"Release","description":"Release automations","version":"v","created_at":"2026-10-01T00:00:00Z","updated_at":"2026-10-01T00:00:00Z"}`,
+ want: `{"id":"tag-1","name":"Release","description":"Release automations","version":"v","created_at":"2026-10-01T00:00:00Z","updated_at":"2026-10-01T00:00:00Z"}`,
+ },
+ {
+ name: "escaped path segments",
+ args: map[string]any{"owner": "own er", "repo": "re/po", "name": "Release"},
+ escapedPath: "/agents/repos/own%20er/re%2Fpo/automation-tags",
+ status: http.StatusCreated,
+ body: `{"id":"tag-1","name":"Release","description":"","version":"v","created_at":"2026-10-01T00:00:00Z","updated_at":"2026-10-01T00:00:00Z"}`,
+ want: `{"id":"tag-1","name":"Release","description":"","version":"v","created_at":"2026-10-01T00:00:00Z","updated_at":"2026-10-01T00:00:00Z"}`,
+ },
+ {
+ name: "sanitized name and description",
+ status: http.StatusCreated,
+ body: `{"id":"tag-1","name":"Hello\u200bWorld","description":"Hello\u200bWorld","version":"v1","created_at":"","updated_at":""}`,
+ want: `{"id":"tag-1","name":"HelloWorld","description":"HelloWorld","version":"v1","created_at":"","updated_at":""}`,
+ },
+ {
+ name: "blank name",
+ args: map[string]any{"owner": "owner", "repo": "repo", "name": " "},
+ wantRequestBody: `{"name":" "}`,
+ status: http.StatusUnprocessableEntity,
+ body: `{"message":"Validation Failed","errors":[{"code":"invalid"}],"documentation_url":"https://docs.github.com/rest"}`,
+ wantError: "Validation Failed",
+ },
+ {name: "conflict", status: http.StatusConflict, body: `{"message":"Conflict","code":"automation_tag_deleted","documentation_url":"https://docs.github.com/rest"}`, wantError: "409"},
+ {name: "non-disclosure", status: http.StatusNotFound, body: `{"message":"not found","documentation_url":"https://docs.github.com/rest"}`, wantError: "404"},
+ {name: "empty denial", status: http.StatusNotFound, wantError: "404"},
+ {name: "bad request", status: http.StatusBadRequest, body: `{"message":"Bad Request"}`, wantError: "400"},
+ {name: "permission denied", status: http.StatusForbidden, body: `{"message":"Forbidden"}`, wantError: "403"},
+ {name: "unauthenticated", status: http.StatusUnauthorized, body: `{"message":"Requires authentication"}`, wantError: "401"},
+ {name: "temporarily unavailable", status: http.StatusServiceUnavailable, body: `{"message":"automation tagging is unavailable","documentation_url":"https://docs.github.com/rest"}`, wantError: "503"},
+ {name: "server error", status: http.StatusInternalServerError, body: `{"message":"Internal Server Error"}`, wantError: "500"},
+ {name: "unexpected success status", status: http.StatusOK, body: `{"id":"tag-1","name":"Release","description":"","version":"v","created_at":"2026-10-01T00:00:00Z","updated_at":"2026-10-01T00:00:00Z"}`, wantError: "200"},
+ {name: "missing name", args: map[string]any{"owner": "owner", "repo": "repo"}, wantError: `missing properties: ["name"]`},
+ {name: "dot owner", args: map[string]any{"owner": ".", "repo": "repo", "name": "Release"}, wantError: `owner must not be "."`},
+ {name: "dot repo", args: map[string]any{"owner": "owner", "repo": ".", "name": "Release"}, wantError: `repo must not be "."`},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ calls := 0
+ client := mustNewGHClient(t, MockHTTPClientWithHandler(func(w http.ResponseWriter, r *http.Request) {
+ calls++
+ if tc.status == 0 {
+ w.WriteHeader(http.StatusCreated)
+ _, err := fmt.Fprint(w, `{}`)
+ require.NoError(t, err)
+ return
+ }
+ escapedPath := tc.escapedPath
+ if escapedPath == "" {
+ escapedPath = "/agents/repos/owner/repo/automation-tags"
+ }
+ assert.Equal(t, "api.github.com", r.URL.Host)
+ assert.Equal(t, http.MethodPost, r.Method)
+ assert.Equal(t, escapedPath, r.URL.EscapedPath())
+ assert.Empty(t, r.URL.RawQuery)
+ var requestBody map[string]any
+ require.NoError(t, json.NewDecoder(r.Body).Decode(&requestBody))
+ idempotencyKey, _ := requestBody["idempotency_key"].(string)
+ assert.NotEmpty(t, idempotencyKey)
+ assert.False(t, seen[idempotencyKey], "reused idempotency key %q", idempotencyKey)
+ seen[idempotencyKey] = true
+ delete(requestBody, "idempotency_key")
+ wantRequestBody := tc.wantRequestBody
+ if wantRequestBody == "" {
+ wantRequestBody = `{"name":"Release"}`
+ }
+ encoded, err := json.Marshal(requestBody)
+ require.NoError(t, err)
+ assert.JSONEq(t, wantRequestBody, string(encoded))
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(tc.status)
+ _, err = fmt.Fprint(w, tc.body)
+ require.NoError(t, err)
+ }))
+ deps := BaseDeps{Client: client}
+ inv, err := NewInventory(translations.NullTranslationHelper).
+ WithToolsets([]string{"copilot_automations"}).Build()
+ require.NoError(t, err)
+ server := mcp.NewServer(&mcp.Implementation{Name: "automation-tags", Version: "v1"}, nil)
+ server.AddReceivingMiddleware(InjectDepsMiddleware(deps))
+ inv.RegisterTools(context.Background(), server, deps)
+ session := connectCommentVisibilityClient(t, server, protocol)
+ list, err := session.ListTools(context.Background(), nil)
+ require.NoError(t, err)
+ byName := map[string]*mcp.Tool{}
+ for _, tool := range list.Tools {
+ byName[tool.Name] = tool
+ }
+ require.Contains(t, byName, "create_automation_tag")
+ args := tc.args
+ if args == nil {
+ args = map[string]any{"owner": "owner", "repo": "repo", "name": "Release"}
+ }
+ result, err := session.CallTool(context.Background(), &mcp.CallToolParams{
+ Name: "create_automation_tag", Arguments: args,
+ })
+ require.NoError(t, err)
+ if tc.status == 0 {
+ assert.Zero(t, calls)
+ } else {
+ assert.Equal(t, 1, calls)
+ }
+ if tc.wantError != "" {
+ require.True(t, result.IsError)
+ assert.Contains(t, getErrorResult(t, result).Text, tc.wantError)
+ assert.Nil(t, result.StructuredContent)
+ return
+ }
+ require.False(t, result.IsError)
+ assert.JSONEq(t, tc.want, getTextResult(t, result).Text)
+ if protocol == inventory.ProtocolVersionMultiRoundTrip {
+ require.NotNil(t, byName["create_automation_tag"].OutputSchema)
+ encoded, err := json.Marshal(result.StructuredContent)
+ require.NoError(t, err)
+ assert.JSONEq(t, tc.want, string(encoded))
+ } else {
+ assert.Nil(t, result.StructuredContent)
+ }
+ })
+ }
+ })
+ }
+}
diff --git a/pkg/github/tools.go b/pkg/github/tools.go
index a6acb1a8ff..bf9d265db9 100644
--- a/pkg/github/tools.go
+++ b/pkg/github/tools.go
@@ -301,6 +301,7 @@ func AllTools(t translations.TranslationHelperFunc, opts ...ToolOption) []invent
RequestCopilotReview(t),
ListAutomationTags(t),
GetAutomationTag(t),
+ CreateAutomationTag(t),
// Copilot issue intents (non-default, opt-in)
AssignCopilotToIssueWithIntent(t),