Skip to content
Closed
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
11 changes: 11 additions & 0 deletions docs/src/content/docs/reference/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,17 @@ tools:

With `cli-proxy: false` and an MCP-backed GitHub mode (`local` or `remote`), MCP servers (including `safeoutputs`) remain available through the MCP protocol, and the CLI-only instructions are omitted from the generated prompt. Run `gh aw fix` to add the explicit setting to existing workflows.

## Tool profiles

Agentic engine integrations can provide opt-in tool profiles through `tools.profile`. Use a string for one profile or an array for multiple profiles:

```yaml
tools:
profile: [profile-a, profile-b]
```

Profiles from imported shared workflows are merged in declaration order, with duplicates removed. Profile names must be nonempty strings; the availability and behavior of each profile are defined by the selected agentic engine integration.

## Tool Timeout Configuration

### Tool Operation Timeout (`tools.timeout`)
Expand Down
70 changes: 70 additions & 0 deletions pkg/parser/frontmatter_merge_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,48 @@ func TestMergeTools(t *testing.T) {
},
},
},
{
name: "merge tool profiles from string and array",
base: map[string]any{"profile": "go"},
additional: map[string]any{"profile": []any{"go"}},
expected: map[string]any{"profile": []any{"go"}},
},
{
name: "normalize profile whitespace",
base: map[string]any{"profile": " go "},
additional: map[string]any{"profile": "go"},
expected: map[string]any{"profile": []any{"go"}},
},
{
name: "merge tool profiles in declaration order",
base: map[string]any{"profile": []any{"go"}},
additional: map[string]any{"profile": "future"},
expected: map[string]any{"profile": []any{"go", "future"}},
},
{
name: "preserve a premerged MCP server named profile",
base: map[string]any{"profile": map[string]any{"type": "http", "url": "https://example.invalid/mcp"}},
additional: map[string]any{"bash": false},
expected: map[string]any{
"profile": map[string]any{"type": "http", "url": "https://example.invalid/mcp"},
"bash": false,
},
},
{
name: "preserve an imported MCP server named profile",
base: map[string]any{"bash": false},
additional: map[string]any{"profile": map[string]any{"type": "http", "url": "https://example.invalid/mcp"}},
expected: map[string]any{
"bash": false,
"profile": map[string]any{"type": "http", "url": "https://example.invalid/mcp"},
},
},
{
name: "merge MCP servers named profile",
base: map[string]any{"profile": map[string]any{"type": "http", "url": "https://example.invalid/mcp"}},
additional: map[string]any{"profile": map[string]any{"type": "http", "url": "https://example.invalid/mcp"}},
expected: map[string]any{"profile": map[string]any{"type": "http", "url": "https://example.invalid/mcp"}},
},
{
name: "merge neutral tools with maps (no Claude-specific logic)",
base: map[string]any{
Expand Down Expand Up @@ -330,6 +372,29 @@ func TestMergeTools(t *testing.T) {
}
}

func TestMergeToolsRejectsInvalidProfileValue(t *testing.T) {
for _, test := range []struct {
name string
base map[string]any
additional map[string]any
}{
{name: "invalid base type", base: map[string]any{"profile": true}, additional: map[string]any{}},
{name: "invalid additional type", base: map[string]any{}, additional: map[string]any{"profile": true}},
{name: "empty array", base: map[string]any{}, additional: map[string]any{"profile": []any{}}},
{name: "mixed array", base: map[string]any{}, additional: map[string]any{"profile": []any{"go", 7}}},
{name: "duplicate array", base: map[string]any{}, additional: map[string]any{"profile": []any{"go", "go"}}},
{name: "blank profile", base: map[string]any{}, additional: map[string]any{"profile": ""}},
{name: "MCP server collision", base: map[string]any{"profile": map[string]any{"type": "http"}}, additional: map[string]any{"profile": "go"}},
{name: "reverse MCP server collision", base: map[string]any{"profile": "go"}, additional: map[string]any{"profile": map[string]any{"type": "http"}}},
} {
t.Run(test.name, func(t *testing.T) {
if _, err := MergeTools(test.base, test.additional); err == nil {
t.Fatal("MergeTools() must reject an invalid imported profile value")
}
})
}
}

func TestMergeToolsFromJSON(t *testing.T) {
tests := []struct {
name string
Expand All @@ -343,6 +408,11 @@ func TestMergeToolsFromJSON(t *testing.T) {
expected: `{"tool1":{"enabled":true},"tool2":{"enabled":false}}`,
wantErr: false,
},
{
name: "single invalid profile object",
content: `{"profile":[]}`,
wantErr: true,
},
{
name: "multiple JSON objects on separate lines",
content: `{"tool1": {"enabled": true}}
Expand Down
32 changes: 32 additions & 0 deletions pkg/parser/schema_tool_profile_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
//go:build !integration

package parser

import "testing"

func TestToolsProfileSchema(t *testing.T) {
for _, test := range []struct {
name string
profile any
wantErr bool
}{
{name: "string", profile: "go"},
{name: "array", profile: []any{"go", "future"}},
{name: "empty string", profile: "", wantErr: true},
{name: "blank string", profile: " ", wantErr: true},
{name: "boolean", profile: true, wantErr: true},
{name: "empty array", profile: []any{}, wantErr: true},
{name: "duplicate array", profile: []any{"go", "go"}, wantErr: true},
{name: "object", profile: map[string]any{"id": "go"}, wantErr: true},
} {
t.Run(test.name, func(t *testing.T) {
err := ValidateMainWorkflowFrontmatterWithSchemaAndLocation(map[string]any{
"on": "workflow_dispatch",
"tools": map[string]any{"profile": test.profile},
}, "profile.md")
if (err != nil) != test.wantErr {
t.Fatalf("schema error = %v, want error %t", err, test.wantErr)
}
})
}
}
20 changes: 20 additions & 0 deletions pkg/parser/schemas/main_workflow_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -5435,6 +5435,26 @@
"description": "When true, each user-facing MCP server is mounted as a standalone CLI tool on PATH. The agent can then call MCP servers via shell commands (e.g. 'github issue_read --method get ...'). CLI-mounted servers remain in the MCP gateway config so their containers can start, and are removed only from the agent's final config during convert_gateway_config_*.sh processing. Default: false.",
"examples": [true]
},
"profile": {
"description": "Opt-in tool profiles interpreted by agentic engine integrations. A single profile can be provided as a string; multiple profiles can be provided as an array and are merged across imports.",
"oneOf": [
{
"type": "string",
"minLength": 1,
"pattern": "\\S"
},
{
"type": "array",
"items": {
"type": "string",
"minLength": 1,
"pattern": "\\S"
},
"minItems": 1,
"uniqueItems": true
}
]
},
"serena": {
"description": "REMOVED: Built-in support for Serena has been removed. Use the shared/mcp/serena.md workflow instead.",
"deprecated": true,
Expand Down
92 changes: 89 additions & 3 deletions pkg/parser/tools_merger.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package parser
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"maps"
"strings"
Expand Down Expand Up @@ -48,15 +49,29 @@ func mergeToolsFromJSON(content string) (string, error) {
}

// MergeTools merges two neutral tool configurations.
// Only supports merging arrays and maps for neutral tools (bash, web-fetch, web-search, edit, mcp-*).
// Supports merging arrays and maps for neutral tools plus the tools.profile string-or-array shorthand.
// Removes all legacy Claude tool merging logic.
func MergeTools(base, additional map[string]any) (map[string]any, error) {
parserLog.Printf("Merging tools: base_keys=%d, additional_keys=%d", len(base), len(additional))
result := make(map[string]any)

if profile, exists := base["profile"]; exists {
if _, isMCPServer := profile.(map[string]any); !isMCPServer {
if _, err := NormalizeToolProfiles(profile); err != nil {
return nil, err
}
}
}
maps.Copy(result, base)

for key, newValue := range additional {
if key == "profile" {
if _, isMCPServer := newValue.(map[string]any); !isMCPServer {
if _, err := NormalizeToolProfiles(newValue); err != nil {
return nil, err
}
}
}
if existingValue, exists := result[key]; exists {
mergedValue, merged, err := mergeExistingToolValue(key, existingValue, newValue)
if err != nil {
Expand All @@ -82,6 +97,13 @@ func marshalSingleToolObject(content string) (string, error, bool) {
if len(singleObj) == 0 {
return "", nil, false
}
if profile, exists := singleObj["profile"]; exists {
if _, isMCPServer := profile.(map[string]any); !isMCPServer {
if _, err := NormalizeToolProfiles(profile); err != nil {
return "", err, true
}
}
}
result, err := json.Marshal(singleObj)
if err != nil {
return "", err, true
Expand Down Expand Up @@ -117,6 +139,18 @@ func mergeToolObjectList(jsonObjects []map[string]any) (map[string]any, error) {
}

func mergeExistingToolValue(key string, existingValue, newValue any) (any, bool, error) {
if key == "profile" {
_, existingIsMCPServer := existingValue.(map[string]any)
_, newIsMCPServer := newValue.(map[string]any)
if existingIsMCPServer != newIsMCPServer {
return nil, false, errors.New("tools.profile cannot be combined with mcp-servers.profile")
}
if !existingIsMCPServer {
merged, err := mergeToolProfiles(existingValue, newValue)
return merged, true, err
}
// Let map-shaped MCP server configurations use the normal map merge path.
}
if existingArray, ok := existingValue.([]any); ok {
if newArray, ok := newValue.([]any); ok {
return mergeAllowedArrays(existingArray, newArray), true, nil
Expand All @@ -143,6 +177,58 @@ func mergeExistingToolValue(key string, existingValue, newValue any) (any, bool,
return recursiveMerged, true, nil
}

func mergeToolProfiles(existingValue, newValue any) ([]any, error) {
existingProfiles, err := NormalizeToolProfiles(existingValue)
if err != nil {
return nil, err
}
newProfiles, err := NormalizeToolProfiles(newValue)
if err != nil {
return nil, err
}
merged := make([]any, 0, len(existingProfiles)+len(newProfiles))
seen := make(map[string]struct{}, len(existingProfiles)+len(newProfiles))
for _, profile := range append(existingProfiles, newProfiles...) {
if !setutil.Contains(seen, profile) {
merged = append(merged, profile)
seen[profile] = struct{}{}
}
}
return merged, nil
}

// NormalizeToolProfiles validates a profile selector and returns canonical profile names.
func NormalizeToolProfiles(value any) ([]string, error) {
var values []any
switch profiles := value.(type) {
case string:
values = []any{profiles}
case []any:
if len(profiles) == 0 {
return nil, errors.New("tools.profile array must not be empty")
}
values = profiles
default:
return nil, errors.New("tools.profile must be a string or an array")
}

result := make([]string, 0, len(values))
seen := make(map[string]struct{}, len(values))
for _, value := range values {
profile, ok := value.(string)
if !ok || strings.TrimSpace(profile) == "" {
return nil, errors.New("tools.profile entries must be nonempty strings")
}
profile = strings.TrimSpace(profile)
if setutil.Contains(seen, profile) {
return nil, fmt.Errorf("tools.profile contains duplicate value %q", profile)
}
result = append(result, profile)
seen[profile] = struct{}{}
}
return result, nil
}

func mergeMCPIfApplicable(key string, existingMap, newMap map[string]any) (map[string]any, bool, error) {
if !hasMCPType(existingMap) || !hasMCPType(newMap) {
return nil, false, nil
Expand All @@ -163,8 +249,8 @@ func hasMCPType(tool map[string]any) bool {
if !ok {
return false
}
mcpType, _ := mcpMap["type"].(string)
return IsMCPType(mcpType)
mcpType, ok := mcpMap["type"].(string)
return ok && IsMCPType(mcpType)
}

func mergeAllowedSubfieldIfPresent(existingMap, newMap map[string]any) (map[string]any, bool) {
Expand Down
7 changes: 5 additions & 2 deletions pkg/workflow/mcp_config_validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ var builtInToolNames = map[string]bool{
"timeout": true,
"startup-timeout": true,
"cli-proxy": true,
"profile": true,
}

// builtInToolNamesForError is the sorted, comma-separated list of built-in tool names
Expand All @@ -68,8 +69,10 @@ func ValidateMCPConfigs(tools map[string]any) error {
for _, toolName := range toolNames {
toolConfig := tools[toolName]

// Skip built-in tools - they have their own schema validation
if builtInToolNames[toolName] && toolName != "linear" {
// Skip built-in tools - they have their own schema validation. A map-shaped
// "profile" entry can be a custom MCP server merged from mcp-servers.
_, isMap := toolConfig.(map[string]any)
if builtInToolNames[toolName] && toolName != "linear" && (toolName != "profile" || !isMap) {
mcpValidationLog.Printf("Skipping MCP validation for built-in tool: %s", toolName)
continue
}
Expand Down
21 changes: 21 additions & 0 deletions pkg/workflow/tool_profiles.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package workflow

import (
"github.com/github/gh-aw/pkg/parser"
)

func extractToolProfiles(tools map[string]any) ([]string, error) {
value, exists := tools["profile"]
if !exists {
return nil, nil
}

switch value := value.(type) {
case map[string]any:
// MCP servers are merged into the tools map for runtime rendering.
// Preserve a custom server named "profile".
return nil, nil
default:
return parser.NormalizeToolProfiles(value)
}
}
Loading
Loading