From 5c039b5ef17bd6b7263d5d2d2517aa74d05a36ca Mon Sep 17 00:00:00 2001 From: Zhijie Huang Date: Fri, 22 May 2026 14:42:00 +0800 Subject: [PATCH 1/4] feat(toolboxes): support RemoteA2A and GroundingWithCustomSearch tool types Adds 2 new project-connection categories as toolbox tools: - RemoteA2A -> tools[].type=a2a_preview - GroundingWithCustomSearch -> tools[].type=web_search with custom_search_configuration Adds --instance-name flag (and connections[].instance_name on --from-file) for the GroundingWithCustomSearch input, mirroring --index for CognitiveSearch. Also fixes toolEntryReferences to recognize the new web_search shape (project_connection_id nested under custom_search_configuration), so connection remove / duplicate detection work for those tools. --- .../internal/cmd/toolbox_commands_test.go | 2 +- .../internal/cmd/toolbox_connection.go | 80 ++++++++++++---- .../internal/cmd/toolbox_connection_add.go | 23 ++++- .../internal/cmd/toolbox_create.go | 2 +- .../internal/cmd/toolbox_files.go | 6 +- .../internal/cmd/toolbox_help.go | 44 ++++++--- .../internal/cmd/toolbox_helpers_test.go | 92 ++++++++++++++++--- .../internal/cmd/toolbox_shared.go | 32 ++++--- .../internal/exterrors/codes.go | 2 + .../internal/foundry/connections/client.go | 3 + 10 files changed, 219 insertions(+), 67 deletions(-) diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_commands_test.go b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_commands_test.go index 59068c10f79..2ef1a1c18c1 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_commands_test.go +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_commands_test.go @@ -451,7 +451,7 @@ func TestBuildToolEntry_RejectsInvalidName(t *testing.T) { Category: connections.ConnectionTypeRemoteTool, Name: "tools.v1", // dot is not in ^[A-Za-z0-9_-]+$ Target: "https://mcp", - }, "") + }, "", "") le := requireLocalError(t, err, exterrors.CodeInvalidToolboxName) assert.Contains(t, le.Message, "tool entry name") assert.Contains(t, le.Message, "tools.v1") diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_connection.go b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_connection.go index 10751857509..59c3bad2009 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_connection.go +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_connection.go @@ -22,9 +22,10 @@ func newToolboxConnectionCommand(extCtx *azdext.ExtensionContext) *cobra.Command Short: "Manage the connection-backed tools attached to a toolbox.", Long: `Manage the connection-backed tools attached to a toolbox. -Tools are project connections (MCP servers via RemoteTool, or Azure AI Search -indexes via CognitiveSearch). Each mutation publishes a new immutable version -and retargets the toolbox default.`, +Tools are project connections. Supported categories: RemoteTool (MCP), +CognitiveSearch (Azure AI Search), RemoteA2A, and GroundingWithCustomSearch. +Each mutation publishes a new immutable version and retargets the toolbox +default.`, } cmd.AddCommand(newToolboxConnectionAddCommand(extCtx)) cmd.AddCommand(newToolboxConnectionRemoveCommand(extCtx)) @@ -33,24 +34,38 @@ and retargets the toolbox default.`, } // buildToolEntry returns the tool-entry map appropriate for the connection's -// category. Enforces the --index flag rules and the `tool.name` regex. -func buildToolEntry(conn *projectConnection, index string) (map[string]any, error) { +// category. Enforces per-input flag rules (--index, --instance-name) and the +// `tool.name` regex. +func buildToolEntry(conn *projectConnection, index, instanceName string) (map[string]any, error) { if err := validateToolName(conn.Name); err != nil { return nil, err } + // --index is only meaningful for CognitiveSearch; reject elsewhere. + if index != "" && conn.Category != connections.ConnectionTypeCognitiveSearch { + return nil, exterrors.Validation( + exterrors.CodeUnsupportedIndexFlag, + fmt.Sprintf( + "--index is only valid for CognitiveSearch connections, "+ + "connection %q has category %q", + conn.Name, conn.Category, + ), + "omit --index for non-CognitiveSearch connections", + ) + } + // --instance-name is only meaningful for GroundingWithCustomSearch. + if instanceName != "" && conn.Category != connections.ConnectionTypeGroundingWithCustomSearch { + return nil, exterrors.Validation( + exterrors.CodeUnsupportedInstanceNameFlag, + fmt.Sprintf( + "--instance-name is only valid for GroundingWithCustomSearch connections, "+ + "connection %q has category %q", + conn.Name, conn.Category, + ), + "omit --instance-name for non-GroundingWithCustomSearch connections", + ) + } switch conn.Category { case connections.ConnectionTypeRemoteTool: - if index != "" { - return nil, exterrors.Validation( - exterrors.CodeUnsupportedIndexFlag, - fmt.Sprintf( - "--index is only valid for CognitiveSearch connections, "+ - "connection %q has category %q", - conn.Name, conn.Category, - ), - "omit --index for RemoteTool (MCP) connections", - ) - } // Reject locally rather than letting the service produce a generic 400. if strings.TrimSpace(conn.Target) == "" { return nil, exterrors.Validation( @@ -94,15 +109,44 @@ func buildToolEntry(conn *projectConnection, index string) (map[string]any, erro }, }, nil + case connections.ConnectionTypeRemoteA2A: + return map[string]any{ + "type": "a2a_preview", + "name": conn.Name, + "project_connection_id": conn.ID, + }, nil + + case connections.ConnectionTypeGroundingWithCustomSearch: + if strings.TrimSpace(instanceName) == "" { + return nil, exterrors.Validation( + exterrors.CodeMissingInstanceName, + fmt.Sprintf( + "connection %q is a GroundingWithCustomSearch connection; "+ + "--instance-name is required", + conn.Name, + ), + "pass --instance-name with the Bing custom-search configuration name", + ) + } + return map[string]any{ + "type": "web_search", + "name": conn.Name, + "custom_search_configuration": map[string]any{ + "project_connection_id": conn.ID, + "instance_name": instanceName, + }, + }, nil + default: return nil, exterrors.Validation( exterrors.CodeUnsupportedConnectionCategory, fmt.Sprintf( "connection %q has category %q which is not supported as a toolbox tool today; "+ - "v1 supports RemoteTool (MCP) and CognitiveSearch (Azure AI Search) only", + "supported categories: RemoteTool (MCP), CognitiveSearch (Azure AI Search), "+ + "RemoteA2A, GroundingWithCustomSearch", conn.Name, conn.Category, ), - "use a RemoteTool (MCP) or CognitiveSearch (Azure AI Search) connection, "+ + "use one of the supported connection categories, "+ "or file an issue requesting support for the connection category you need", ) } diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_connection_add.go b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_connection_add.go index e429c4e8609..e60e6ad85c9 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_connection_add.go +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_connection_add.go @@ -18,8 +18,9 @@ import ( // connectionAddFlags carries the verb-specific flags for `connection add`. type connectionAddFlags struct { - index string - fromFile string + index string + instanceName string + fromFile string } // newToolboxConnectionAddCommand returns the `connection add` command. @@ -40,6 +41,7 @@ Single-connection mode: Pass the project connection's short name as the positional. --index is required when the connection's category is CognitiveSearch (Azure AI Search). +--instance-name is required when the category is GroundingWithCustomSearch. Only one tool is appended; the new version becomes the default. File mode: @@ -62,6 +64,9 @@ Examples: # Attach a CognitiveSearch connection with an explicit index azd ai toolbox add research my-search --index products + # Attach a GroundingWithCustomSearch connection with a Bing custom-search instance + azd ai toolbox add research my-bing --instance-name docs-config + # Attach several tools in one new version azd ai toolbox add research --from-file ./tools.yaml --output json `, @@ -95,6 +100,11 @@ Examples: &flags.index, "index", "", "Search index name. Required for CognitiveSearch (Azure AI Search) connections; ignored otherwise.", ) + cmd.Flags().StringVar( + &flags.instanceName, "instance-name", "", + "Bing custom-search configuration name. "+ + "Required for GroundingWithCustomSearch connections; ignored otherwise.", + ) cmd.Flags().StringVar( &flags.fromFile, "from-file", "", "Path to a JSON/YAML file describing the connections to add (see --help for the file shape).", @@ -175,6 +185,13 @@ func runConnectionAddWith( "set connection indexes in the file under connections[].index", ) } + if verb.instanceName != "" { + return exterrors.Validation( + exterrors.CodeUnsupportedInstanceNameFlag, + "--instance-name cannot be used together with --from-file", + "set connection instance names in the file under connections[].instance_name", + ) + } var input toolboxToolsFile if err := parseToolboxFile(verb.fromFile, &input); err != nil { @@ -193,7 +210,7 @@ func runConnectionAddWith( if err != nil { return err } - entry, err := buildToolEntry(conn, verb.index) + entry, err := buildToolEntry(conn, verb.index, verb.instanceName) if err != nil { return err } diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_create.go b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_create.go index edc3017d1d2..befe79c88cf 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_create.go +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_create.go @@ -187,7 +187,7 @@ func resolveConnectionSpecs( if err != nil { return nil, err } - entry, err := buildToolEntry(conn, spec.Index) + entry, err := buildToolEntry(conn, spec.Index, spec.InstanceName) if err != nil { return nil, err } diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_files.go b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_files.go index acf11f39369..f84866d0c7c 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_files.go +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_files.go @@ -18,9 +18,11 @@ import ( // toolboxConnectionSpec is one connection-backed tool input. // For CognitiveSearch connections, Index is required. +// For GroundingWithCustomSearch connections, InstanceName is required. type toolboxConnectionSpec struct { - Name string `json:"name" yaml:"name"` - Index string `json:"index,omitempty" yaml:"index,omitempty"` + Name string `json:"name" yaml:"name"` + Index string `json:"index,omitempty" yaml:"index,omitempty"` + InstanceName string `json:"instance_name,omitempty" yaml:"instance_name,omitempty"` } // toolboxToolsFile is the file shape for `toolbox connection add --from-file`. diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_help.go b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_help.go index 3280a5fb11c..34ee76bbd8f 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_help.go +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_help.go @@ -17,7 +17,9 @@ func fileShapeBlurb(includeDescription bool) string { "description": "research toolbox", "connections": [ { "name": "my-mcp" }, - { "name": "my-search", "index": "products" } + { "name": "my-search", "index": "products" }, + { "name": "my-bing", "instance_name": "docs-config" }, + { "name": "my-a2a" } ] } @@ -28,15 +30,20 @@ Equivalent YAML: - name: my-mcp - name: my-search index: products + - name: my-bing + instance_name: docs-config + - name: my-a2a Fields: - description Optional. Stored on the initial toolbox version. - connections Required. List of existing project connections to attach. - Each entry needs 'name' (the project connection short name). - 'index' is required only for CognitiveSearch connections and - is the search index name inside that service. - Supported connection categories: RemoteTool (MCP), - CognitiveSearch (Azure AI Search). + description Optional. Stored on the initial toolbox version. + connections Required. List of existing project connections to attach. + Each entry needs 'name' (the project connection short name). + 'index' is required only for CognitiveSearch connections. + 'instance_name' is required only for + GroundingWithCustomSearch connections. + Supported connection categories: RemoteTool (MCP), + CognitiveSearch (Azure AI Search), RemoteA2A, + GroundingWithCustomSearch. Project connections must already exist on the Foundry project; this command does not create them. Run 'azd ai agent connection list' to see available @@ -48,7 +55,9 @@ connections.` { "connections": [ { "name": "my-mcp" }, - { "name": "my-search", "index": "products" } + { "name": "my-search", "index": "products" }, + { "name": "my-bing", "instance_name": "docs-config" }, + { "name": "my-a2a" } ] } @@ -58,14 +67,19 @@ Equivalent YAML: - name: my-mcp - name: my-search index: products + - name: my-bing + instance_name: docs-config + - name: my-a2a Fields: - connections Required. List of existing project connections to attach. - Each entry needs 'name' (the project connection short name). - 'index' is required only for CognitiveSearch connections and - is the search index name inside that service. - Supported connection categories: RemoteTool (MCP), - CognitiveSearch (Azure AI Search). + connections Required. List of existing project connections to attach. + Each entry needs 'name' (the project connection short name). + 'index' is required only for CognitiveSearch connections. + 'instance_name' is required only for + GroundingWithCustomSearch connections. + Supported connection categories: RemoteTool (MCP), + CognitiveSearch (Azure AI Search), RemoteA2A, + GroundingWithCustomSearch. The toolbox's existing description is carried forward unchanged; use 'azd ai toolbox update' to change it. diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_helpers_test.go b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_helpers_test.go index 850322ad222..820146874f6 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_helpers_test.go +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_helpers_test.go @@ -70,7 +70,7 @@ func TestBuildToolEntry(t *testing.T) { Category: connections.ConnectionTypeRemoteTool, Name: "my-mcp", Target: "https://mcp.example.com", - }, "") + }, "", "") require.NoError(t, err) assert.Equal(t, "mcp", entry["type"]) assert.Equal(t, "my-mcp", entry["name"]) @@ -83,17 +83,26 @@ func TestBuildToolEntry(t *testing.T) { _, err := buildToolEntry(&projectConnection{ Category: connections.ConnectionTypeRemoteTool, Name: "my-mcp", - }, "idx") + }, "idx", "") requireLocalError(t, err, exterrors.CodeUnsupportedIndexFlag) }) + t.Run("RemoteTool rejects --instance-name", func(t *testing.T) { + _, err := buildToolEntry(&projectConnection{ + Category: connections.ConnectionTypeRemoteTool, + Name: "my-mcp", + Target: "https://mcp.example.com", + }, "", "inst") + requireLocalError(t, err, exterrors.CodeUnsupportedInstanceNameFlag) + }) + t.Run("RemoteTool rejects empty target", func(t *testing.T) { _, err := buildToolEntry(&projectConnection{ ID: "/c/x", Category: connections.ConnectionTypeRemoteTool, Name: "x", Target: " ", // whitespace-only is treated as empty - }, "") + }, "", "") le := requireLocalError(t, err, exterrors.CodeConnectionMissingTarget) assert.Contains(t, le.Message, "target URL") }) @@ -102,7 +111,7 @@ func TestBuildToolEntry(t *testing.T) { _, err := buildToolEntry(&projectConnection{ Category: connections.ConnectionTypeCognitiveSearch, Name: "search", - }, "") + }, "", "") requireLocalError(t, err, exterrors.CodeMissingIndex) }) @@ -111,7 +120,7 @@ func TestBuildToolEntry(t *testing.T) { ID: "/subs/x/.../connections/search", Category: connections.ConnectionTypeCognitiveSearch, Name: "search", - }, "products") + }, "products", "") require.NoError(t, err) assert.Equal(t, "azure_ai_search", entry["type"]) search := entry["azure_ai_search"].(map[string]any) @@ -122,13 +131,46 @@ func TestBuildToolEntry(t *testing.T) { assert.Equal(t, "/subs/x/.../connections/search", first["project_connection_id"]) }) + t.Run("RemoteA2A builds a2a_preview entry", func(t *testing.T) { + entry, err := buildToolEntry(&projectConnection{ + ID: "/subs/x/.../connections/my-a2a", + Category: connections.ConnectionTypeRemoteA2A, + Name: "my-a2a", + }, "", "") + require.NoError(t, err) + assert.Equal(t, "a2a_preview", entry["type"]) + assert.Equal(t, "my-a2a", entry["name"]) + assert.Equal(t, "/subs/x/.../connections/my-a2a", entry["project_connection_id"]) + }) + + t.Run("GroundingWithCustomSearch requires --instance-name", func(t *testing.T) { + _, err := buildToolEntry(&projectConnection{ + Category: connections.ConnectionTypeGroundingWithCustomSearch, + Name: "bing", + }, "", "") + requireLocalError(t, err, exterrors.CodeMissingInstanceName) + }) + + t.Run("GroundingWithCustomSearch builds web_search entry", func(t *testing.T) { + entry, err := buildToolEntry(&projectConnection{ + ID: "/subs/x/.../connections/bing", + Category: connections.ConnectionTypeGroundingWithCustomSearch, + Name: "bing", + }, "", "docs-config") + require.NoError(t, err) + assert.Equal(t, "web_search", entry["type"]) + cfg := entry["custom_search_configuration"].(map[string]any) + assert.Equal(t, "/subs/x/.../connections/bing", cfg["project_connection_id"]) + assert.Equal(t, "docs-config", cfg["instance_name"]) + }) + t.Run("unsupported category rejected", func(t *testing.T) { for _, cat := range []connections.ConnectionType{ connections.ConnectionTypeApiKey, connections.ConnectionTypeCustomKeys, connections.ConnectionTypeAppInsights, } { - _, err := buildToolEntry(&projectConnection{Category: cat, Name: "x"}, "") + _, err := buildToolEntry(&projectConnection{Category: cat, Name: "x"}, "", "") le := requireLocalError(t, err, exterrors.CodeUnsupportedConnectionCategory) assert.Contains(t, le.Message, string(cat), "expected category in message") @@ -147,10 +189,19 @@ func TestDuplicateConnectionInTools(t *testing.T) { }, }, }, + { + "type": "web_search", + "custom_search_configuration": map[string]any{ + "project_connection_id": "/conn/d", "instance_name": "inst", + }, + }, + {"type": "a2a_preview", "project_connection_id": "/conn/f"}, } assert.True(t, duplicateConnectionInTools(tools, "/conn/a")) assert.True(t, duplicateConnectionInTools(tools, "/conn/b")) - assert.False(t, duplicateConnectionInTools(tools, "/conn/c")) + assert.True(t, duplicateConnectionInTools(tools, "/conn/d")) + assert.True(t, duplicateConnectionInTools(tools, "/conn/f")) + assert.False(t, duplicateConnectionInTools(tools, "/conn/zzz")) } func TestFilterOutConnection(t *testing.T) { @@ -167,23 +218,38 @@ func TestFilterOutConnection(t *testing.T) { }, }, }, + { + "type": "web_search", + "name": "ws", + "custom_search_configuration": map[string]any{ + "project_connection_id": "/conn/d", "instance_name": "inst", + }, + }, + {"type": "a2a_preview", "name": "a2a", "project_connection_id": "/conn/f"}, } got, removed := filterOutConnection(tools, "/conn/a") assert.True(t, removed) - assert.Len(t, got, 3) - for _, e := range got { - assert.NotEqual(t, "/conn/a", e["project_connection_id"]) - } + assert.Len(t, got, 5) // Removing missing connection: removed=false, slice unchanged in length. got2, removed2 := filterOutConnection(tools, "/conn/zzz") assert.False(t, removed2) - assert.Len(t, got2, 4) + assert.Len(t, got2, 6) // Removing nested search connection. got3, removed3 := filterOutConnection(tools, "/conn/c") assert.True(t, removed3) - assert.Len(t, got3, 3) + assert.Len(t, got3, 5) + + // Removing web_search (custom_search_configuration nested). + got4, removed4 := filterOutConnection(tools, "/conn/d") + assert.True(t, removed4) + assert.Len(t, got4, 5) + + // Removing a2a_preview (top-level project_connection_id). + got6, removed6 := filterOutConnection(tools, "/conn/f") + assert.True(t, removed6) + assert.Len(t, got6, 5) } func TestShortConnectionName(t *testing.T) { diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_shared.go b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_shared.go index c6a91aad7d4..f4bd3aa4c09 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_shared.go +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_shared.go @@ -38,25 +38,29 @@ func forEachToolConnectionID(tools []map[string]any, fn func(connID string) bool } // toolEntryReferences runs match against every connection ID referenced by a -// single tool entry and returns true on the first hit. +// single tool entry and returns true on the first hit. Recognized shapes: +// - top-level `project_connection_id` (mcp, a2a_preview) +// - `azure_ai_search.indexes[].project_connection_id` +// - `custom_search_configuration.project_connection_id` (web_search) func toolEntryReferences(t map[string]any, match func(connID string) bool) bool { if id, ok := t["project_connection_id"].(string); ok && id != "" && match(id) { return true } - search, ok := t["azure_ai_search"].(map[string]any) - if !ok { - return false - } - indexes, ok := search["indexes"].([]any) - if !ok { - return false - } - for _, idx := range indexes { - m, ok := idx.(map[string]any) - if !ok { - continue + if search, ok := t["azure_ai_search"].(map[string]any); ok { + if indexes, ok := search["indexes"].([]any); ok { + for _, idx := range indexes { + m, ok := idx.(map[string]any) + if !ok { + continue + } + if id, ok := m["project_connection_id"].(string); ok && id != "" && match(id) { + return true + } + } } - if id, ok := m["project_connection_id"].(string); ok && id != "" && match(id) { + } + if cfg, ok := t["custom_search_configuration"].(map[string]any); ok { + if id, ok := cfg["project_connection_id"].(string); ok && id != "" && match(id) { return true } } diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/exterrors/codes.go b/cli/azd/extensions/azure.ai.toolboxes/internal/exterrors/codes.go index da4357f85a6..2f0026f1c7d 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/internal/exterrors/codes.go +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/exterrors/codes.go @@ -38,6 +38,8 @@ const ( CodeUnsupportedConnectionCategory = "unsupported_connection_category" CodeMissingIndex = "missing_index" CodeUnsupportedIndexFlag = "unsupported_index_flag" + CodeMissingInstanceName = "missing_instance_name" + CodeUnsupportedInstanceNameFlag = "unsupported_instance_name_flag" CodeDuplicateConnection = "duplicate_connection" CodeConnectionNotFound = "connection_not_found" CodeConnectionNotInToolbox = "connection_not_in_toolbox" diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/foundry/connections/client.go b/cli/azd/extensions/azure.ai.toolboxes/internal/foundry/connections/client.go index b855ab473f4..9b364b5f548 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/internal/foundry/connections/client.go +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/foundry/connections/client.go @@ -41,6 +41,9 @@ const ( ConnectionTypeAppInsights ConnectionType = "AppInsights" ConnectionTypeCustomKeys ConnectionType = "CustomKeys" ConnectionTypeRemoteTool ConnectionType = "RemoteTool" + // Additional tool-capable connection categories surfaced as toolbox tools. + ConnectionTypeRemoteA2A ConnectionType = "RemoteA2A" + ConnectionTypeGroundingWithCustomSearch ConnectionType = "GroundingWithCustomSearch" ) // CredentialType is the credential kind reported on a connection. From 977f5aeb9b2730cc2d30595b0821dd1c220c9fff Mon Sep 17 00:00:00 2001 From: Zhijie Huang Date: Fri, 22 May 2026 16:57:35 +0800 Subject: [PATCH 2/4] fix(toolboxes): address copilot review feedback - Fix stale 'azd ai toolbox add' usage strings and examples (the command is registered under 'toolbox connection add'); pre-existing typo, worsened by this PR's new examples - Flag help for --index and --instance-name says 'Only valid for ...; required there' to match buildToolEntry's actual rejection behavior (was misleading 'ignored otherwise') - Normalize --index and --instance-name with strings.TrimSpace at the top of buildToolEntry so whitespace-only values are treated consistently (previously rejected as 'unsupported flag' instead of treated as empty) - TestFilterOutConnection: assert removed connection IDs no longer appear in any tool-entry shape, not just slice length --- .../internal/cmd/toolbox_connection.go | 9 +++++-- .../internal/cmd/toolbox_connection_add.go | 16 ++++++------ .../internal/cmd/toolbox_helpers_test.go | 26 +++++++++++++++++++ 3 files changed, 41 insertions(+), 10 deletions(-) diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_connection.go b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_connection.go index 59c3bad2009..8575017c315 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_connection.go +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_connection.go @@ -40,6 +40,11 @@ func buildToolEntry(conn *projectConnection, index, instanceName string) (map[st if err := validateToolName(conn.Name); err != nil { return nil, err } + // Normalize whitespace-only inputs up front so cross-category flag + // rejection and required-input validation agree (e.g. `--index " "` + // should not be treated as "user supplied a value"). + index = strings.TrimSpace(index) + instanceName = strings.TrimSpace(instanceName) // --index is only meaningful for CognitiveSearch; reject elsewhere. if index != "" && conn.Category != connections.ConnectionTypeCognitiveSearch { return nil, exterrors.Validation( @@ -86,7 +91,7 @@ func buildToolEntry(conn *projectConnection, index, instanceName string) (map[st }, nil case connections.ConnectionTypeCognitiveSearch: - if strings.TrimSpace(index) == "" { + if index == "" { return nil, exterrors.Validation( exterrors.CodeMissingIndex, fmt.Sprintf( @@ -117,7 +122,7 @@ func buildToolEntry(conn *projectConnection, index, instanceName string) (map[st }, nil case connections.ConnectionTypeGroundingWithCustomSearch: - if strings.TrimSpace(instanceName) == "" { + if instanceName == "" { return nil, exterrors.Validation( exterrors.CodeMissingInstanceName, fmt.Sprintf( diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_connection_add.go b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_connection_add.go index e60e6ad85c9..3394384f5d8 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_connection_add.go +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_connection_add.go @@ -37,7 +37,7 @@ This command has two modes: Single-connection mode: - azd ai toolbox add [--index ] + azd ai toolbox connection add [--index ] [--instance-name ] Pass the project connection's short name as the positional. --index is required when the connection's category is CognitiveSearch (Azure AI Search). @@ -46,7 +46,7 @@ Only one tool is appended; the new version becomes the default. File mode: - azd ai toolbox add --from-file + azd ai toolbox connection add --from-file Provide a JSON or YAML file with multiple connections. All inputs from a single invocation publish exactly one new toolbox version, so adding three @@ -59,16 +59,16 @@ At least one connection must be provided. Examples: # Attach a single RemoteTool (MCP) connection - azd ai toolbox add research my-mcp + azd ai toolbox connection add research my-mcp # Attach a CognitiveSearch connection with an explicit index - azd ai toolbox add research my-search --index products + azd ai toolbox connection add research my-search --index products # Attach a GroundingWithCustomSearch connection with a Bing custom-search instance - azd ai toolbox add research my-bing --instance-name docs-config + azd ai toolbox connection add research my-bing --instance-name docs-config # Attach several tools in one new version - azd ai toolbox add research --from-file ./tools.yaml --output json + azd ai toolbox connection add research --from-file ./tools.yaml --output json `, Args: func(cmd *cobra.Command, args []string) error { fromFile, _ := cmd.Flags().GetString("from-file") @@ -98,12 +98,12 @@ Examples: cmd.Flags().StringVar( &flags.index, "index", "", - "Search index name. Required for CognitiveSearch (Azure AI Search) connections; ignored otherwise.", + "Search index name. Only valid for CognitiveSearch (Azure AI Search) connections; required there.", ) cmd.Flags().StringVar( &flags.instanceName, "instance-name", "", "Bing custom-search configuration name. "+ - "Required for GroundingWithCustomSearch connections; ignored otherwise.", + "Only valid for GroundingWithCustomSearch connections; required there.", ) cmd.Flags().StringVar( &flags.fromFile, "from-file", "", diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_helpers_test.go b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_helpers_test.go index 820146874f6..bb986b49d1e 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_helpers_test.go +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_helpers_test.go @@ -87,6 +87,17 @@ func TestBuildToolEntry(t *testing.T) { requireLocalError(t, err, exterrors.CodeUnsupportedIndexFlag) }) + t.Run("RemoteTool treats whitespace-only --index as empty", func(t *testing.T) { + entry, err := buildToolEntry(&projectConnection{ + ID: "/c/x", + Category: connections.ConnectionTypeRemoteTool, + Name: "x", + Target: "https://mcp.example.com", + }, " ", " ") + require.NoError(t, err) + assert.Equal(t, "mcp", entry["type"]) + }) + t.Run("RemoteTool rejects --instance-name", func(t *testing.T) { _, err := buildToolEntry(&projectConnection{ Category: connections.ConnectionTypeRemoteTool, @@ -227,9 +238,21 @@ func TestFilterOutConnection(t *testing.T) { }, {"type": "a2a_preview", "name": "a2a", "project_connection_id": "/conn/f"}, } + // assertNoneReference asserts the removed connection ID is not referenced + // by any remaining tool entry, anywhere in the recognized shapes. + assertNoneReference := func(t *testing.T, entries []map[string]any, connID string) { + t.Helper() + for _, e := range entries { + if toolEntryReferences(e, func(id string) bool { return id == connID }) { + t.Errorf("entry %#v still references %q", e, connID) + } + } + } + got, removed := filterOutConnection(tools, "/conn/a") assert.True(t, removed) assert.Len(t, got, 5) + assertNoneReference(t, got, "/conn/a") // Removing missing connection: removed=false, slice unchanged in length. got2, removed2 := filterOutConnection(tools, "/conn/zzz") @@ -240,16 +263,19 @@ func TestFilterOutConnection(t *testing.T) { got3, removed3 := filterOutConnection(tools, "/conn/c") assert.True(t, removed3) assert.Len(t, got3, 5) + assertNoneReference(t, got3, "/conn/c") // Removing web_search (custom_search_configuration nested). got4, removed4 := filterOutConnection(tools, "/conn/d") assert.True(t, removed4) assert.Len(t, got4, 5) + assertNoneReference(t, got4, "/conn/d") // Removing a2a_preview (top-level project_connection_id). got6, removed6 := filterOutConnection(tools, "/conn/f") assert.True(t, removed6) assert.Len(t, got6, 5) + assertNoneReference(t, got6, "/conn/f") } func TestShortConnectionName(t *testing.T) { From 17fd1656e0ecfc2b766aa4bfc9cf44bcbab5623b Mon Sep 17 00:00:00 2001 From: Zhijie Huang Date: Fri, 22 May 2026 17:33:38 +0800 Subject: [PATCH 3/4] fix(toolboxes): surface a2a_preview and web_search in show/list - extractConnectionTools (toolbox connection list) now handles a2a_preview (top-level project_connection_id) and the connection-backed web_search variant (custom_search_configuration.project_connection_id). Distinguishes the connection-backed web_search from the built-in by presence of custom_search_configuration. - describeToolDetail (toolbox show) now decides between '(connection:)' and '(builtin)' purely from the entry's shape (via firstConnectionID -> toolEntryReferences). Drops the unused toolType parameter. New connection-backed tool shapes recognized by toolEntryReferences are surfaced automatically without touching this helper. --- .../internal/cmd/toolbox_connection_list.go | 25 ++++++++++++++++++- .../internal/cmd/toolbox_show.go | 20 +++++++-------- 2 files changed, 33 insertions(+), 12 deletions(-) diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_connection_list.go b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_connection_list.go index 0a1d0dd9bd4..2265e934e93 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_connection_list.go +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_connection_list.go @@ -79,13 +79,17 @@ func runConnectionListWith( // extractConnectionTools collapses the tool list to one row per connection-backed // entry, surfacing the short connection name parsed from the trailing segment // of the connection ARM ID (the `connection` column in `connection list`). +// +// Rows are emitted for every tool entry that references at least one +// project_connection_id. Built-in tool types (code_interpreter, file_search, +// etc.) carry no connection reference and are skipped automatically. func extractConnectionTools(tools []map[string]any) []map[string]string { rows := []map[string]string{} for _, t := range tools { toolType, _ := t["type"].(string) toolName, _ := t["name"].(string) switch toolType { - case "mcp": + case "mcp", "a2a_preview": if id, ok := t["project_connection_id"].(string); ok && id != "" { rows = append(rows, map[string]string{ "name": toolName, @@ -114,6 +118,25 @@ func extractConnectionTools(tools []map[string]any) []map[string]string { } } } + case "web_search": + // Built-in web_search has no custom_search_configuration; only the + // GroundingWithCustomSearch variant carries a project_connection_id. + cfg, _ := t["custom_search_configuration"].(map[string]any) + if cfg == nil { + continue + } + id, _ := cfg["project_connection_id"].(string) + if id == "" { + continue + } + instance, _ := cfg["instance_name"].(string) + rows = append(rows, map[string]string{ + "name": toolName, + "connection": shortConnectionName(id), + "connection_id": id, + "type": toolType, + "instance_name": instance, + }) } } return rows diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_show.go b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_show.go index 7f081a35326..065b83e49e7 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_show.go +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_show.go @@ -152,7 +152,7 @@ func emitShowTable( for _, tool := range version.Tools { toolName, _ := tool["name"].(string) toolType, _ := tool["type"].(string) - detail := describeToolDetail(toolType, tool) + detail := describeToolDetail(tool) fmt.Fprintf(tw, "%s\t%s\t%s\n", toolName, toolType, detail) } if err := tw.Flush(); err != nil { @@ -163,17 +163,15 @@ func emitShowTable( } // describeToolDetail returns the per-tool annotation used in the show table: -// "(builtin)" for first-party tools and "(connection:)" for connection-backed entries. -func describeToolDetail(toolType string, tool map[string]any) string { - switch toolType { - case "code_interpreter", "web_search", "file_search": - return "(builtin)" - case "mcp", "azure_ai_search": - if id := firstConnectionID(tool); id != "" { - return "(connection:" + id + ")" - } +// "(connection:)" when the entry references a project connection (in any +// recognized shape), otherwise "(builtin)". Driving this off the tool entry's +// shape rather than a hardcoded type allow-list means new connection-backed +// tool types are surfaced automatically. +func describeToolDetail(tool map[string]any) string { + if id := firstConnectionID(tool); id != "" { + return "(connection:" + id + ")" } - return "" + return "(builtin)" } // firstConnectionID returns the first project_connection_id referenced by a From 500c6e1f9e615d9609dc677b08d8568dfee31a2a Mon Sep 17 00:00:00 2001 From: Zhijie Huang Date: Fri, 22 May 2026 17:54:21 +0800 Subject: [PATCH 4/4] test(toolboxes): cover extractConnectionTools branches - TestExtractConnectionTools: pins one row per connection-backed entry for mcp, azure_ai_search, a2a_preview, and the GroundingWithCustomSearch web_search variant; asserts the per-row fields (connection_id, index, instance_name). - TestExtractConnectionTools_SkipsMalformedEntries: confirms built-in web_search (no custom_search_configuration) and entries with empty/missing project_connection_id never produce a row, locking in the gating logic that distinguishes connection-backed web_search from the built-in variant. --- .../internal/cmd/toolbox_helpers_test.go | 112 ++++++++++++++++++ 1 file changed, 112 insertions(+) diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_helpers_test.go b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_helpers_test.go index bb986b49d1e..f6ddc8c4a6b 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_helpers_test.go +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_helpers_test.go @@ -301,3 +301,115 @@ func TestBuildToolboxMcpURL(t *testing.T) { ) assert.Contains(t, escaped, "versions/v%201%2F2/mcp") } + +func TestExtractConnectionTools(t *testing.T) { + tools := []map[string]any{ + // Connection-backed mcp. + { + "type": "mcp", + "name": "gh", + "project_connection_id": "/conn/gh", + }, + // Connection-backed azure_ai_search. + { + "type": "azure_ai_search", + "name": "search", + "azure_ai_search": map[string]any{ + "indexes": []any{ + map[string]any{ + "project_connection_id": "/conn/search", + "index_name": "products", + }, + }, + }, + }, + // Connection-backed a2a_preview. + { + "type": "a2a_preview", + "name": "a2a", + "project_connection_id": "/conn/a2a", + }, + // Connection-backed web_search (GroundingWithCustomSearch). + { + "type": "web_search", + "name": "bing", + "custom_search_configuration": map[string]any{ + "project_connection_id": "/conn/bing", + "instance_name": "docs-config", + }, + }, + // Built-in web_search (no custom_search_configuration) — must be skipped. + { + "type": "web_search", + "name": "builtin-ws", + }, + // Other built-ins — never emit rows. + {"type": "code_interpreter", "name": "ci"}, + {"type": "file_search", "name": "fs"}, + } + + rows := extractConnectionTools(tools) + require.Len(t, rows, 4, "expected one row per connection-backed entry; built-in web_search must be skipped") + + byName := map[string]map[string]string{} + for _, r := range rows { + byName[r["name"]] = r + } + + gh := byName["gh"] + require.NotNil(t, gh) + assert.Equal(t, "mcp", gh["type"]) + assert.Equal(t, "/conn/gh", gh["connection_id"]) + assert.Equal(t, "gh", gh["connection"]) + assert.Empty(t, gh["index"]) + assert.Empty(t, gh["instance_name"]) + + search := byName["search"] + require.NotNil(t, search) + assert.Equal(t, "azure_ai_search", search["type"]) + assert.Equal(t, "/conn/search", search["connection_id"]) + assert.Equal(t, "products", search["index"]) + + a2a := byName["a2a"] + require.NotNil(t, a2a) + assert.Equal(t, "a2a_preview", a2a["type"]) + assert.Equal(t, "/conn/a2a", a2a["connection_id"]) + + bing := byName["bing"] + require.NotNil(t, bing) + assert.Equal(t, "web_search", bing["type"]) + assert.Equal(t, "/conn/bing", bing["connection_id"]) + assert.Equal(t, "docs-config", bing["instance_name"]) + + // Confirm the built-in and other built-in tools never produced a row. + assert.NotContains(t, byName, "builtin-ws", "built-in web_search must be skipped") + assert.NotContains(t, byName, "ci") + assert.NotContains(t, byName, "fs") +} + +func TestExtractConnectionTools_SkipsMalformedEntries(t *testing.T) { + tools := []map[string]any{ + // mcp without a project_connection_id is not surfaced. + {"type": "mcp", "name": "no-id"}, + // mcp with empty project_connection_id is not surfaced. + {"type": "mcp", "name": "empty-id", "project_connection_id": ""}, + // web_search whose custom_search_configuration has no project_connection_id. + { + "type": "web_search", + "name": "no-cfg-id", + "custom_search_configuration": map[string]any{ + "instance_name": "x", + }, + }, + // web_search whose custom_search_configuration.project_connection_id is empty. + { + "type": "web_search", + "name": "empty-cfg-id", + "custom_search_configuration": map[string]any{ + "project_connection_id": "", + "instance_name": "x", + }, + }, + } + assert.Empty(t, extractConnectionTools(tools)) +}