Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -779,6 +779,12 @@ For negotiated supported protocol versions `2026-07-28` or newer, the consolidat

<summary><picture><source media="(prefers-color-scheme: dark)" srcset="pkg/octicons/icons/workflow-dark.png"><source media="(prefers-color-scheme: light)" srcset="pkg/octicons/icons/workflow-light.png"><img src="pkg/octicons/icons/workflow-light.png" width="20" height="20" alt="workflow"></picture> Copilot Automations</summary>

- **get_automation_tag** - Get automation tag
- **OAuth Challenge Scopes**: `repo`
- `owner`: Repository owner (string, required)
- `repo`: Repository name (string, required)
- `tag_id`: Automation tag ID, as returned by list_automation_tags (string, required)

- **list_automation_tags** - List automation tags
- **OAuth Challenge Scopes**: `repo`
- `owner`: Repository owner (string, required)
Expand Down
34 changes: 34 additions & 0 deletions pkg/github/__toolsnaps__/get_automation_tag.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
{
"annotations": {
"idempotentHint": false,
"readOnlyHint": true,
"title": "Get automation tag"
},
"description": "Get a caller-visible automation tag in a repository by ID. Tags organize automations; they do not execute them. A tag that is missing, deleted, or not visible to the caller returns a 404 error.",
"inputSchema": {
"properties": {
"owner": {
"description": "Repository owner",
"minLength": 1,
"type": "string"
},
"repo": {
"description": "Repository name",
"minLength": 1,
"type": "string"
},
"tag_id": {
"description": "Automation tag ID, as returned by list_automation_tags",
"minLength": 1,
"type": "string"
}
},
"required": [
"owner",
"repo",
"tag_id"
],
"type": "object"
},
"name": "get_automation_tag"
}
75 changes: 75 additions & 0 deletions pkg/github/automation_tags.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,81 @@ func ListAutomationTags(t translations.TranslationHelperFunc) inventory.ServerTo
)
}

type GetAutomationTagInput struct {
Owner string `json:"owner"`
Repo string `json:"repo"`
TagID string `json:"tag_id"`
}

func GetAutomationTag(t translations.TranslationHelperFunc) inventory.ServerTool {
return NewTool[GetAutomationTagInput, *AutomationTag](
ToolsetMetadataCopilotAutomations,
mcp.Tool{
Name: "get_automation_tag",
Description: t("TOOL_GET_AUTOMATION_TAG_DESCRIPTION", "Get a caller-visible automation tag in a repository by ID. Tags organize automations; they do not execute them. A tag that is missing, deleted, or not visible to the caller returns a 404 error."),
Annotations: &mcp.ToolAnnotations{
Title: t("TOOL_GET_AUTOMATION_TAG_USER_TITLE", "Get automation tag"),
ReadOnlyHint: 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)},
"tag_id": {Type: "string", Description: "Automation tag ID, as returned by list_automation_tags", MinLength: new(1)},
},
Required: []string{"owner", "repo", "tag_id"},
},
},
scopes.RequireAll(scopes.Repo),
func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input GetAutomationTagInput) (*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.TagID == "" {
return utils.NewToolResultError("missing required parameter: tag_id"), nil, nil
}
if input.Owner == "." {
Comment thread
Copilot marked this conversation as resolved.
return utils.NewToolResultError(`owner must not be "."`), nil, nil
}
if input.Repo == "." {
return utils.NewToolResultError(`repo must not be "."`), nil, nil
}
// URL resolution removes a "." segment, which would send this request to
// ".../automation-tags/". NewRequest already rejects "..".
if input.TagID == "." {
return utils.NewToolResultError(`tag_id 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)
}
apiURL := fmt.Sprintf("agents/repos/%s/%s/automation-tags/%s",
url.PathEscape(input.Owner), url.PathEscape(input.Repo), url.PathEscape(input.TagID))
req, err := client.NewRequest(ctx, http.MethodGet, apiURL, nil)
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 get automation tag", resp, err), nil, nil
}
if resp.StatusCode != http.StatusOK {
return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get automation tag", resp, nil), nil, nil
}
sanitizeAutomationTagNameAndDescription(&output)
return MarshalledTextResult(&output), &output, nil
Comment thread
notapopsicle marked this conversation as resolved.
Comment thread
notapopsicle marked this conversation as resolved.
},
)
}

func sanitizeAutomationTagNameAndDescription(tag *AutomationTag) {
tag.Name = sanitize.PlainText(tag.Name)
tag.Description = sanitize.Content(tag.Description)
Expand Down
191 changes: 188 additions & 3 deletions pkg/github/automation_tags_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -192,8 +192,11 @@ func Test_ListAutomationTags(t *testing.T) {
session := connectCommentVisibilityClient(t, server, protocol)
list, err := session.ListTools(context.Background(), nil)
require.NoError(t, err)
require.Len(t, list.Tools, 1)
assert.Equal(t, "list_automation_tags", list.Tools[0].Name)
byName := map[string]*mcp.Tool{}
for _, tool := range list.Tools {
byName[tool.Name] = tool
}
require.Contains(t, byName, "list_automation_tags")
args := tc.args
if args == nil {
args = map[string]any{"owner": "owner", "repo": "repo"}
Expand All @@ -216,7 +219,189 @@ func Test_ListAutomationTags(t *testing.T) {
require.False(t, result.IsError)
assert.JSONEq(t, tc.want, getTextResult(t, result).Text)
if protocol == inventory.ProtocolVersionMultiRoundTrip {
require.NotNil(t, list.Tools[0].OutputSchema)
require.NotNil(t, byName["list_automation_tags"].OutputSchema)
encoded, err := json.Marshal(result.StructuredContent)
require.NoError(t, err)
assert.JSONEq(t, tc.want, string(encoded))
} else {
assert.Nil(t, result.StructuredContent)
}
})
}
})
}
}

func Test_GetAutomationTag(t *testing.T) {
toolDef := GetAutomationTag(translations.NullTranslationHelper)
require.NoError(t, toolsnaps.Test(toolDef.Tool.Name, toolDef.Tool))
assert.True(t, toolDef.Tool.Annotations.ReadOnlyHint)

t.Run("requires owner repo and tag id", func(t *testing.T) {
for _, tc := range []struct {
name string
args map[string]any
wantError string
}{
{"missing owner", map[string]any{"repo": "repo", "tag_id": "tag-1"}, "owner"},
{"empty owner", map[string]any{"owner": "", "repo": "repo", "tag_id": "tag-1"}, "owner"},
{"missing repo", map[string]any{"owner": "owner", "tag_id": "tag-1"}, "repo"},
{"empty repo", map[string]any{"owner": "owner", "repo": "", "tag_id": "tag-1"}, "repo"},
{"missing tag id", map[string]any{"owner": "owner", "repo": "repo"}, "tag_id"},
{"empty tag id", map[string]any{"owner": "owner", "repo": "repo", "tag_id": ""}, "tag_id"},
} {
t.Run(tc.name, func(t *testing.T) {
calls := 0
client := mustNewGHClient(t, MockHTTPClientWithHandler(func(w http.ResponseWriter, _ *http.Request) {
calls++
_, 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
want bool
}{
{"default", []string{"default"}, []string{"repo"}, false},
{"opt in read only", []string{"copilot_automations"}, []string{"repo"}, true},
{"missing scope", []string{"copilot_automations"}, []string{}, false},
} {
t.Run(tc.name, func(t *testing.T) {
inv, err := NewInventory(translations.NullTranslationHelper).
WithToolsets(tc.toolsets).WithReadOnly(true).
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 == "get_automation_tag"
}
assert.Equal(t, tc.want, found)
})
}
})

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
want string
wantError string
}{
{
name: "internal and additive fields",
status: http.StatusOK,
body: `{"id":"tag-1","name":"Release","description":"","version":"opaque-version","created_at":"2026-10-01T00:00:00Z","updated_at":"2026-10-02T00:00:00Z","deleted_at":"2026-10-03T00: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-02T00:00:00Z"}`,
},
{
name: "escaped path segments",
args: map[string]any{"owner": "own er", "repo": "re/po", "tag_id": "a/b c"},
escapedPath: "/agents/repos/own%20er/re%2Fpo/automation-tags/a%2Fb%20c",
status: http.StatusOK,
body: `{"id":"a/b c","name":"Release","description":"","version":"v","created_at":"2026-10-01T00:00:00Z","updated_at":"2026-10-02T00:00:00Z"}`,
want: `{"id":"a/b c","name":"Release","description":"","version":"v","created_at":"2026-10-01T00:00:00Z","updated_at":"2026-10-02T00:00:00Z"}`,
},
{
name: "sanitized name and description",
status: http.StatusOK,
body: `{"id":"tag-1","name":"<script>alert(1)</script>Hello\u200bWorld","description":"<script>alert(1)</script>Hello\u200bWorld","version":"v1","created_at":"","updated_at":""}`,
want: `{"id":"tag-1","name":"HelloWorld","description":"<script>alert(1)</script>HelloWorld","version":"v1","created_at":"","updated_at":""}`,
},
{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: "conflict", status: http.StatusConflict, body: `{"message":"Conflict"}`, wantError: "409"},
{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: "validation", status: http.StatusUnprocessableEntity, body: `{"message":"Validation Failed"}`, wantError: "Validation Failed"},
{name: "unexpected success status", status: http.StatusNoContent, wantError: "204"},
{name: "missing tag id", args: map[string]any{"owner": "owner", "repo": "repo"}, wantError: "tag_id"},
{name: "empty tag id", args: map[string]any{"owner": "owner", "repo": "repo", "tag_id": ""}, wantError: "tag_id"},
{name: "dot tag id", args: map[string]any{"owner": "owner", "repo": "repo", "tag_id": "."}, wantError: `tag_id must not be "."`},
{name: "dot owner", args: map[string]any{"owner": ".", "repo": "repo", "tag_id": "tag-1"}, wantError: `owner must not be "."`},
{name: "dot repo", args: map[string]any{"owner": "owner", "repo": ".", "tag_id": "tag-1"}, 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 {
_, err := fmt.Fprint(w, `{}`)
require.NoError(t, err)
return
}
escapedPath := tc.escapedPath
if escapedPath == "" {
escapedPath = "/agents/repos/owner/repo/automation-tags/tag-1"
}
assert.Equal(t, "api.github.com", r.URL.Host)
assert.Equal(t, http.MethodGet, r.Method)
assert.Equal(t, escapedPath, r.URL.EscapedPath())
assert.Empty(t, r.URL.RawQuery)
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"}).WithReadOnly(true).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, "get_automation_tag")
args := tc.args
if args == nil {
args = map[string]any{"owner": "owner", "repo": "repo", "tag_id": "tag-1"}
}
result, err := session.CallTool(context.Background(), &mcp.CallToolParams{
Name: "get_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["get_automation_tag"].OutputSchema)
encoded, err := json.Marshal(result.StructuredContent)
require.NoError(t, err)
assert.JSONEq(t, tc.want, string(encoded))
Expand Down
1 change: 1 addition & 0 deletions pkg/github/tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,7 @@ func AllTools(t translations.TranslationHelperFunc, opts ...ToolOption) []invent
AssignCopilotToIssue(t),
RequestCopilotReview(t),
ListAutomationTags(t),
GetAutomationTag(t),
Comment thread
notapopsicle marked this conversation as resolved.

// Copilot issue intents (non-default, opt-in)
AssignCopilotToIssueWithIntent(t),
Expand Down
Loading