From b3db8e41844051bb9d6bdf88cb8ac3b7f37aa079 Mon Sep 17 00:00:00 2001 From: John Miller Date: Mon, 23 Mar 2026 16:11:26 -0400 Subject: [PATCH 1/5] feat: implement toolset management commands (create, delete, list, show) and API integration --- .../azure.ai.agents/internal/cmd/root.go | 1 + .../azure.ai.agents/internal/cmd/toolset.go | 26 +++ .../internal/cmd/toolset_create.go | 196 +++++++++++++++++ .../internal/cmd/toolset_delete.go | 96 +++++++++ .../internal/cmd/toolset_list.go | 113 ++++++++++ .../internal/cmd/toolset_show.go | 132 ++++++++++++ .../internal/cmd/toolset_test.go | 177 +++++++++++++++ .../internal/exterrors/codes.go | 15 ++ .../pkg/agents/agent_api/toolset_models.go | 83 +++++++ .../agents/agent_api/toolset_models_test.go | 65 ++++++ .../agents/agent_api/toolset_operations.go | 203 ++++++++++++++++++ 11 files changed, 1107 insertions(+) create mode 100644 cli/azd/extensions/azure.ai.agents/internal/cmd/toolset.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/cmd/toolset_create.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/cmd/toolset_delete.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/cmd/toolset_list.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/cmd/toolset_show.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/cmd/toolset_test.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/toolset_models.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/toolset_models_test.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/toolset_operations.go diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/root.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/root.go index ecd9aee52c9..bef87b36c30 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/root.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/root.go @@ -78,6 +78,7 @@ func NewRootCommand() *cobra.Command { rootCmd.AddCommand(newShowCommand()) rootCmd.AddCommand(newMonitorCommand()) rootCmd.AddCommand(newFilesCommand()) + rootCmd.AddCommand(newToolsetCommand()) return rootCmd } diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/toolset.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/toolset.go new file mode 100644 index 00000000000..1be38814e34 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/toolset.go @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "github.com/spf13/cobra" +) + +func newToolsetCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "toolset", + Short: "Manage Foundry toolsets.", + Long: `Manage Foundry toolsets in the current Azure AI Foundry project. + +Toolsets are named collections of tools (MCP servers, OpenAPI endpoints, first-party tools) +exposed through a unified MCP-compatible endpoint with platform-managed auth.`, + } + + cmd.AddCommand(newToolsetListCommand()) + cmd.AddCommand(newToolsetShowCommand()) + cmd.AddCommand(newToolsetCreateCommand()) + cmd.AddCommand(newToolsetDeleteCommand()) + + return cmd +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/toolset_create.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/toolset_create.go new file mode 100644 index 00000000000..731628ab0af --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/toolset_create.go @@ -0,0 +1,196 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "strings" + + "azureaiagent/internal/exterrors" + "azureaiagent/internal/pkg/agents/agent_api" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/azure/azure-dev/cli/azd/pkg/output" + "github.com/spf13/cobra" +) + +func newToolsetCreateCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "create ", + Short: "Create a toolset in the Foundry project.", + Long: `Create a new toolset from a JSON payload file. + +The payload file must contain a JSON object with at least "name" and "tools" fields. +If a toolset with the same name already exists, you will be prompted to confirm +before overwriting (use --no-prompt to auto-confirm).`, + Example: ` # Create a toolset from a JSON file + azd ai agent toolset create toolset.json + + # Create with auto-confirm for scripting + azd ai agent toolset create toolset.json --no-prompt`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := azdext.WithAccessToken(cmd.Context()) + setupDebugLogging(cmd.Flags()) + + if len(args) == 0 { + return exterrors.Validation( + exterrors.CodeInvalidToolsetPayload, + "missing required payload file path", + "Provide a path to a toolset JSON file, for example:\n"+ + " azd ai agent toolset create path/to/toolset.json", + ) + } + + payloadPath := args[0] + + // Read and parse the payload file + data, err := os.ReadFile(payloadPath) //nolint:gosec // G304: path is from user CLI arg, validated below + if err != nil { + return exterrors.Validation( + exterrors.CodeInvalidToolsetPayload, + fmt.Sprintf("failed to read payload file '%s': %s", payloadPath, err), + "Check that the file path is correct and the file is readable", + ) + } + + var createReq agent_api.CreateToolsetRequest + if err := json.Unmarshal(data, &createReq); err != nil { + return exterrors.Validation( + exterrors.CodeInvalidToolsetPayload, + fmt.Sprintf("failed to parse payload file '%s': %s", payloadPath, err), + "Ensure the file contains valid JSON with 'name' and 'tools' fields", + ) + } + + if createReq.Name == "" { + return exterrors.Validation( + exterrors.CodeInvalidToolsetPayload, + "toolset payload is missing required 'name' field", + "Add a 'name' field to the JSON payload", + ) + } + if len(createReq.Tools) == 0 { + return exterrors.Validation( + exterrors.CodeInvalidToolsetPayload, + "toolset payload is missing required 'tools' field or tools array is empty", + "Add a 'tools' array with at least one tool definition", + ) + } + + endpoint, err := resolveAgentEndpoint(ctx, "", "") + if err != nil { + return err + } + + credential, err := newAgentCredential() + if err != nil { + return exterrors.Auth( + exterrors.CodeCredentialCreationFailed, + fmt.Sprintf("failed to create credential: %s", err), + "Run 'azd auth login' to authenticate", + ) + } + + client := agent_api.NewAgentClient(endpoint, credential) + + // Check if toolset already exists + existing, err := client.GetToolset(ctx, createReq.Name, agent_api.ToolsetAPIVersion) + if err == nil && existing != nil { + // Toolset exists — prompt for overwrite confirmation + if !rootFlags.NoPrompt { + azdClient, azdErr := azdext.NewAzdClient() + if azdErr != nil { + return fmt.Errorf("failed to create azd client for prompting: %w", azdErr) + } + defer azdClient.Close() + + resp, promptErr := azdClient.Prompt().Confirm(ctx, &azdext.ConfirmRequest{ + Options: &azdext.ConfirmOptions{ + Message: fmt.Sprintf( + "Toolset '%s' already exists with %d tool(s). Overwrite?", + existing.Name, len(existing.Tools), + ), + }, + }) + if promptErr != nil { + if exterrors.IsCancellation(promptErr) { + return exterrors.Cancelled("toolset creation cancelled") + } + return fmt.Errorf("failed to prompt for confirmation: %w", promptErr) + } + if !*resp.Value { + fmt.Println("Toolset creation cancelled.") + return nil + } + } + + // Update the existing toolset + updateReq := &agent_api.UpdateToolsetRequest{ + Description: createReq.Description, + Metadata: createReq.Metadata, + Tools: createReq.Tools, + } + + toolset, updateErr := client.UpdateToolset(ctx, createReq.Name, updateReq, agent_api.ToolsetAPIVersion) + if updateErr != nil { + return exterrors.ServiceFromAzure(updateErr, exterrors.OpUpdateToolset) + } + + mcpEndpoint := fmt.Sprintf("%s/toolsets/%s/mcp", endpoint, toolset.Name) + fmt.Printf("Toolset '%s' updated successfully (%d tool(s)).\n", toolset.Name, len(toolset.Tools)) + fmt.Printf("MCP Endpoint: %s\n", mcpEndpoint) + printMcpEnvTip(toolset.Name, mcpEndpoint) + return nil + } + + // Check if the error is a 404 (not found) — proceed with create + var respErr *azcore.ResponseError + if err != nil && !(errors.As(err, &respErr) && respErr.StatusCode == 404) { + return exterrors.ServiceFromAzure(err, exterrors.OpGetToolset) + } + + // Create new toolset + toolset, createErr := client.CreateToolset(ctx, &createReq, agent_api.ToolsetAPIVersion) + if createErr != nil { + return exterrors.ServiceFromAzure(createErr, exterrors.OpCreateToolset) + } + + mcpEndpoint := fmt.Sprintf("%s/toolsets/%s/mcp", endpoint, toolset.Name) + fmt.Printf("Toolset '%s' created successfully (%d tool(s)).\n", toolset.Name, len(toolset.Tools)) + fmt.Printf("MCP Endpoint: %s\n", mcpEndpoint) + printMcpEnvTip(toolset.Name, mcpEndpoint) + return nil + }, + } + + return cmd +} + +// toolsetNameToEnvVar converts a toolset name to an environment variable name +// by upper-casing and replacing non-alphanumeric characters with underscores. +func toolsetNameToEnvVar(name string) string { + var b strings.Builder + for _, r := range strings.ToUpper(name) { + if (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') { + b.WriteRune(r) + } else { + b.WriteByte('_') + } + } + return b.String() +} + +func printMcpEnvTip(toolsetName, mcpEndpoint string) { + envVar := toolsetNameToEnvVar(toolsetName) + "_MCP_ENDPOINT" + fmt.Println() + fmt.Println(output.WithHintFormat( + "Hint: Store the endpoint in your azd environment so your agent code can reference it:")) + fmt.Printf(" %s\n", output.WithHighLightFormat( + "azd env set %s %s", envVar, mcpEndpoint)) +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/toolset_delete.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/toolset_delete.go new file mode 100644 index 00000000000..6c1265e1325 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/toolset_delete.go @@ -0,0 +1,96 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "errors" + "fmt" + + "azureaiagent/internal/exterrors" + "azureaiagent/internal/pkg/agents/agent_api" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/spf13/cobra" +) + +func newToolsetDeleteCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete a toolset from the Foundry project.", + Long: `Delete a toolset by name from the current Azure AI Foundry project. + +You will be prompted to confirm before deleting (use --no-prompt to auto-confirm).`, + Example: ` # Delete a toolset (with confirmation prompt) + azd ai agent toolset delete my-toolset + + # Delete without prompting + azd ai agent toolset delete my-toolset --no-prompt`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + name := args[0] + ctx := azdext.WithAccessToken(cmd.Context()) + setupDebugLogging(cmd.Flags()) + + endpoint, err := resolveAgentEndpoint(ctx, "", "") + if err != nil { + return err + } + + credential, err := newAgentCredential() + if err != nil { + return exterrors.Auth( + exterrors.CodeCredentialCreationFailed, + fmt.Sprintf("failed to create credential: %s", err), + "Run 'azd auth login' to authenticate", + ) + } + + // Prompt for confirmation + if !rootFlags.NoPrompt { + azdClient, azdErr := azdext.NewAzdClient() + if azdErr != nil { + return fmt.Errorf("failed to create azd client for prompting: %w", azdErr) + } + defer azdClient.Close() + + resp, promptErr := azdClient.Prompt().Confirm(ctx, &azdext.ConfirmRequest{ + Options: &azdext.ConfirmOptions{ + Message: fmt.Sprintf("Are you sure you want to delete toolset '%s'?", name), + }, + }) + if promptErr != nil { + if exterrors.IsCancellation(promptErr) { + return exterrors.Cancelled("toolset deletion cancelled") + } + return fmt.Errorf("failed to prompt for confirmation: %w", promptErr) + } + if !*resp.Value { + fmt.Println("Toolset deletion cancelled.") + return nil + } + } + + client := agent_api.NewAgentClient(endpoint, credential) + + _, err = client.DeleteToolset(ctx, name, agent_api.ToolsetAPIVersion) + if err != nil { + var respErr *azcore.ResponseError + if errors.As(err, &respErr) && respErr.StatusCode == 404 { + return exterrors.Validation( + exterrors.CodeToolsetNotFound, + fmt.Sprintf("toolset '%s' not found", name), + "Run 'azd ai agent toolset list' to see available toolsets", + ) + } + return exterrors.ServiceFromAzure(err, exterrors.OpDeleteToolset) + } + + fmt.Printf("Toolset '%s' deleted successfully.\n", name) + return nil + }, + } + + return cmd +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/toolset_list.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/toolset_list.go new file mode 100644 index 00000000000..14d680a3dbc --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/toolset_list.go @@ -0,0 +1,113 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "os" + "text/tabwriter" + "time" + + "azureaiagent/internal/exterrors" + "azureaiagent/internal/pkg/agents/agent_api" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/spf13/cobra" +) + +type toolsetListFlags struct { + output string +} + +func newToolsetListCommand() *cobra.Command { + flags := &toolsetListFlags{} + + cmd := &cobra.Command{ + Use: "list", + Short: "List all toolsets in the Foundry project.", + Long: `List all toolsets in the current Azure AI Foundry project. + +Displays the name, description, number of tools, and creation time +for each toolset. Requires AZURE_AI_PROJECT_ENDPOINT in the azd environment.`, + Example: ` # List toolsets in table format (default) + azd ai agent toolset list + + # List toolsets as JSON + azd ai agent toolset list --output json`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := azdext.WithAccessToken(cmd.Context()) + setupDebugLogging(cmd.Flags()) + + endpoint, err := resolveAgentEndpoint(ctx, "", "") + if err != nil { + return err + } + + credential, err := newAgentCredential() + if err != nil { + return exterrors.Auth( + exterrors.CodeCredentialCreationFailed, + fmt.Sprintf("failed to create credential: %s", err), + "Run 'azd auth login' to authenticate", + ) + } + + client := agent_api.NewAgentClient(endpoint, credential) + list, err := client.ListToolsets(ctx, agent_api.ToolsetAPIVersion) + if err != nil { + return exterrors.ServiceFromAzure(err, exterrors.OpListToolsets) + } + + switch flags.output { + case "json": + return printToolsetListJSON(list) + default: + return printToolsetListTable(ctx, list) + } + }, + } + + cmd.Flags().StringVarP(&flags.output, "output", "o", "table", "Output format (json or table)") + + return cmd +} + +func printToolsetListJSON(list *agent_api.ToolsetList) error { + jsonBytes, err := json.MarshalIndent(list, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal toolset list to JSON: %w", err) + } + fmt.Println(string(jsonBytes)) + return nil +} + +func printToolsetListTable(_ context.Context, list *agent_api.ToolsetList) error { + if len(list.Data) == 0 { + fmt.Println("No toolsets found.") + return nil + } + + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(w, "NAME\tDESCRIPTION\tTOOLS\tCREATED") + fmt.Fprintln(w, "----\t-----------\t-----\t-------") + + for _, ts := range list.Data { + desc := ts.Description + if len(desc) > 50 { + desc = desc[:47] + "..." + } + + created := "" + if ts.CreatedAt > 0 { + created = time.Unix(ts.CreatedAt, 0).Format(time.RFC3339) + } + + fmt.Fprintf(w, "%s\t%s\t%d\t%s\n", ts.Name, desc, len(ts.Tools), created) + } + + return w.Flush() +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/toolset_show.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/toolset_show.go new file mode 100644 index 00000000000..c24b77fe548 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/toolset_show.go @@ -0,0 +1,132 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "encoding/json" + "fmt" + "os" + "text/tabwriter" + "time" + + "azureaiagent/internal/exterrors" + "azureaiagent/internal/pkg/agents/agent_api" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/spf13/cobra" +) + +type toolsetShowFlags struct { + output string +} + +func newToolsetShowCommand() *cobra.Command { + flags := &toolsetShowFlags{} + + cmd := &cobra.Command{ + Use: "show ", + Short: "Show details of a toolset.", + Long: `Show details of a Foundry toolset by name. + +Displays the toolset's properties, included tools, and the MCP endpoint URL +that can be used to connect an agent to the toolset.`, + Example: ` # Show toolset details as JSON (default) + azd ai agent toolset show my-toolset + + # Show toolset details as a table + azd ai agent toolset show my-toolset --output table`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + name := args[0] + ctx := azdext.WithAccessToken(cmd.Context()) + setupDebugLogging(cmd.Flags()) + + endpoint, err := resolveAgentEndpoint(ctx, "", "") + if err != nil { + return err + } + + credential, err := newAgentCredential() + if err != nil { + return exterrors.Auth( + exterrors.CodeCredentialCreationFailed, + fmt.Sprintf("failed to create credential: %s", err), + "Run 'azd auth login' to authenticate", + ) + } + + client := agent_api.NewAgentClient(endpoint, credential) + toolset, err := client.GetToolset(ctx, name, agent_api.ToolsetAPIVersion) + if err != nil { + return exterrors.ServiceFromAzure(err, exterrors.OpGetToolset) + } + + mcpEndpoint := fmt.Sprintf("%s/toolsets/%s/mcp", endpoint, name) + + switch flags.output { + case "table": + return printToolsetShowTable(toolset, mcpEndpoint) + default: + return printToolsetShowJSON(toolset, mcpEndpoint) + } + }, + } + + cmd.Flags().StringVarP(&flags.output, "output", "o", "json", "Output format (json or table)") + + return cmd +} + +// toolsetShowOutput wraps the toolset object with the computed MCP endpoint for JSON output. +type toolsetShowOutput struct { + agent_api.ToolsetObject + MCPEndpoint string `json:"mcp_endpoint"` +} + +func printToolsetShowJSON(toolset *agent_api.ToolsetObject, mcpEndpoint string) error { + output := toolsetShowOutput{ + ToolsetObject: *toolset, + MCPEndpoint: mcpEndpoint, + } + + jsonBytes, err := json.MarshalIndent(output, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal toolset to JSON: %w", err) + } + fmt.Println(string(jsonBytes)) + return nil +} + +func printToolsetShowTable(toolset *agent_api.ToolsetObject, mcpEndpoint string) error { + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(w, "FIELD\tVALUE") + fmt.Fprintln(w, "-----\t-----") + + fmt.Fprintf(w, "Name\t%s\n", toolset.Name) + fmt.Fprintf(w, "ID\t%s\n", toolset.ID) + if toolset.Description != "" { + fmt.Fprintf(w, "Description\t%s\n", toolset.Description) + } + + if toolset.CreatedAt > 0 { + fmt.Fprintf(w, "Created\t%s\n", time.Unix(toolset.CreatedAt, 0).Format(time.RFC3339)) + } + if toolset.UpdatedAt > 0 { + fmt.Fprintf(w, "Updated\t%s\n", time.Unix(toolset.UpdatedAt, 0).Format(time.RFC3339)) + } + + fmt.Fprintf(w, "Tools\t%d\n", len(toolset.Tools)) + for i, raw := range toolset.Tools { + toolType, toolName := agent_api.ToolSummary(raw) + label := toolType + if toolName != "" { + label = fmt.Sprintf("%s (%s)", toolType, toolName) + } + fmt.Fprintf(w, " Tool %d\t%s\n", i+1, label) + } + + fmt.Fprintf(w, "MCP Endpoint\t%s\n", mcpEndpoint) + + return w.Flush() +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/toolset_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/toolset_test.go new file mode 100644 index 00000000000..b1f46120e04 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/toolset_test.go @@ -0,0 +1,177 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "encoding/json" + "testing" + + "azureaiagent/internal/pkg/agents/agent_api" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestToolsetCommand_HasSubcommands(t *testing.T) { + cmd := newToolsetCommand() + + subcommands := cmd.Commands() + names := make([]string, len(subcommands)) + for i, c := range subcommands { + names[i] = c.Name() + } + + assert.Contains(t, names, "list") + assert.Contains(t, names, "show") + assert.Contains(t, names, "create") + assert.Contains(t, names, "delete") +} + +func TestToolsetListCommand_DefaultOutputFormat(t *testing.T) { + cmd := newToolsetListCommand() + + output, _ := cmd.Flags().GetString("output") + assert.Equal(t, "table", output) +} + +func TestToolsetListCommand_HasFlags(t *testing.T) { + cmd := newToolsetListCommand() + + f := cmd.Flags().Lookup("output") + require.NotNil(t, f, "expected flag 'output'") + assert.Equal(t, "o", f.Shorthand) +} + +func TestToolsetShowCommand_RequiresArg(t *testing.T) { + cmd := newToolsetShowCommand() + + cmd.SetArgs([]string{}) + err := cmd.Execute() + assert.Error(t, err) +} + +func TestToolsetShowCommand_DefaultOutputFormat(t *testing.T) { + cmd := newToolsetShowCommand() + + output, _ := cmd.Flags().GetString("output") + assert.Equal(t, "json", output) +} + +func TestToolsetShowCommand_HasFlags(t *testing.T) { + cmd := newToolsetShowCommand() + + f := cmd.Flags().Lookup("output") + require.NotNil(t, f, "expected flag 'output'") + assert.Equal(t, "o", f.Shorthand) +} + +func TestToolsetCreateCommand_AcceptsOneArg(t *testing.T) { + cmd := newToolsetCreateCommand() + assert.NotNil(t, cmd.Args) +} + +func TestToolsetDeleteCommand_RequiresArg(t *testing.T) { + cmd := newToolsetDeleteCommand() + + cmd.SetArgs([]string{}) + err := cmd.Execute() + assert.Error(t, err) +} + +func TestToolsetNameToEnvVar(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + {"simple", "my-toolset", "MY_TOOLSET"}, + {"already upper", "MY_TOOLSET", "MY_TOOLSET"}, + {"dots and spaces", "my.toolset name", "MY_TOOLSET_NAME"}, + {"numeric", "tools123", "TOOLS123"}, + {"empty", "", ""}, + {"special chars", "a@b#c", "A_B_C"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := toolsetNameToEnvVar(tt.in) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestPrintToolsetListTable_Empty(t *testing.T) { + list := &agent_api.ToolsetList{} + err := printToolsetListTable(t.Context(), list) + require.NoError(t, err) +} + +func TestPrintToolsetListJSON_Empty(t *testing.T) { + list := &agent_api.ToolsetList{} + err := printToolsetListJSON(list) + require.NoError(t, err) +} + +func TestPrintToolsetListTable_WithData(t *testing.T) { + list := &agent_api.ToolsetList{ + Data: []agent_api.ToolsetObject{ + { + Name: "test-toolset", + Description: "A test toolset", + Tools: []json.RawMessage{json.RawMessage(`{"type":"mcp_server"}`)}, + CreatedAt: 1700000000, + }, + { + Name: "long-desc", + Description: "This description is longer than fifty characters and should be truncated", + Tools: nil, + }, + }, + } + + err := printToolsetListTable(t.Context(), list) + require.NoError(t, err) +} + +func TestPrintToolsetListJSON_WithData(t *testing.T) { + list := &agent_api.ToolsetList{ + Data: []agent_api.ToolsetObject{ + { + Name: "test-toolset", + Tools: []json.RawMessage{json.RawMessage(`{"type":"mcp_server"}`)}, + }, + }, + } + + err := printToolsetListJSON(list) + require.NoError(t, err) +} + +func TestPrintToolsetShowJSON(t *testing.T) { + toolset := &agent_api.ToolsetObject{ + Name: "my-toolset", + ID: "ts-123", + Tools: []json.RawMessage{json.RawMessage(`{"type":"openapi"}`)}, + } + + err := printToolsetShowJSON(toolset, "https://example.com/toolsets/my-toolset/mcp") + require.NoError(t, err) +} + +func TestPrintToolsetShowTable(t *testing.T) { + toolset := &agent_api.ToolsetObject{ + Name: "my-toolset", + ID: "ts-123", + Description: "Test toolset", + CreatedAt: 1700000000, + UpdatedAt: 1700001000, + Tools: []json.RawMessage{ + json.RawMessage(`{"type":"mcp_server","server_label":"my-mcp"}`), + json.RawMessage(`{"type":"openapi"}`), + }, + } + + err := printToolsetShowTable(toolset, "https://example.com/toolsets/my-toolset/mcp") + require.NoError(t, err) +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/exterrors/codes.go b/cli/azd/extensions/azure.ai.agents/internal/exterrors/codes.go index 045e6ee226a..f9fceffc4ed 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/exterrors/codes.go +++ b/cli/azd/extensions/azure.ai.agents/internal/exterrors/codes.go @@ -86,6 +86,21 @@ const ( CodeInvalidFilePath = "invalid_file_path" ) +// Error codes for toolset operations. +const ( + CodeToolsetNotFound = "toolset_not_found" + CodeInvalidToolsetPayload = "invalid_toolset_payload" +) + +// Operation names for toolset [ServiceFromAzure] errors. +const ( + OpListToolsets = "list_toolsets" + OpGetToolset = "get_toolset" + OpCreateToolset = "create_toolset" + OpUpdateToolset = "update_toolset" + OpDeleteToolset = "delete_toolset" +) + // Error codes commonly used for internal errors. // // These are usually paired with [Internal] for unexpected failures diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/toolset_models.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/toolset_models.go new file mode 100644 index 00000000000..9c0c23975fb --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/toolset_models.go @@ -0,0 +1,83 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package agent_api + +import "encoding/json" + +// ToolsetAPIVersion is the API version for toolset operations. +const ToolsetAPIVersion = "v1" + +// ToolsetFeatureHeader is the required preview feature flag header for toolset operations. +const ToolsetFeatureHeader = "Toolsets=V1Preview" + +// ToolsetObject represents a toolset returned by the Foundry Toolsets API. +type ToolsetObject struct { + Object string `json:"object"` + ID string `json:"id"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` + Tools []json.RawMessage `json:"tools"` +} + +// ToolsetList represents the paginated list response from the Toolsets API. +type ToolsetList struct { + Data []ToolsetObject `json:"data"` +} + +// DeleteToolsetResponse represents the response from deleting a toolset. +type DeleteToolsetResponse struct { + Object string `json:"object"` + Name string `json:"name"` + Deleted bool `json:"deleted"` +} + +// CreateToolsetRequest represents the request body for creating a toolset. +type CreateToolsetRequest struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` + Tools []json.RawMessage `json:"tools"` +} + +// UpdateToolsetRequest represents the request body for updating a toolset. +type UpdateToolsetRequest struct { + Description string `json:"description,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` + Tools []json.RawMessage `json:"tools"` +} + +// ToolSummary extracts a display-friendly summary from a raw tool JSON object. +// Returns the tool type and name (if available). +func ToolSummary(raw json.RawMessage) (toolType string, toolName string) { + var m map[string]json.RawMessage + if err := json.Unmarshal(raw, &m); err != nil { + return "unknown", "" + } + + if t, ok := m["type"]; ok { + var s string + if json.Unmarshal(t, &s) == nil { + toolType = s + } + } + if toolType == "" { + toolType = "unknown" + } + + // Try common name fields + for _, key := range []string{"server_label", "name"} { + if v, ok := m[key]; ok { + var s string + if json.Unmarshal(v, &s) == nil && s != "" { + toolName = s + break + } + } + } + + return toolType, toolName +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/toolset_models_test.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/toolset_models_test.go new file mode 100644 index 00000000000..250cf317abd --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/toolset_models_test.go @@ -0,0 +1,65 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package agent_api + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestToolSummary(t *testing.T) { + tests := []struct { + name string + raw json.RawMessage + wantType string + wantName string + }{ + { + name: "mcp_server with server_label", + raw: json.RawMessage(`{"type":"mcp_server","server_label":"my-mcp"}`), + wantType: "mcp_server", + wantName: "my-mcp", + }, + { + name: "openapi with name", + raw: json.RawMessage(`{"type":"openapi","name":"weather-api"}`), + wantType: "openapi", + wantName: "weather-api", + }, + { + name: "server_label takes precedence over name", + raw: json.RawMessage(`{"type":"mcp_server","server_label":"label","name":"fallback"}`), + wantType: "mcp_server", + wantName: "label", + }, + { + name: "type only no name", + raw: json.RawMessage(`{"type":"bing_grounding"}`), + wantType: "bing_grounding", + wantName: "", + }, + { + name: "empty object", + raw: json.RawMessage(`{}`), + wantType: "unknown", + wantName: "", + }, + { + name: "invalid json", + raw: json.RawMessage(`not-json`), + wantType: "unknown", + wantName: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotType, gotName := ToolSummary(tt.raw) + assert.Equal(t, tt.wantType, gotType) + assert.Equal(t, tt.wantName, gotName) + }) + } +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/toolset_operations.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/toolset_operations.go new file mode 100644 index 00000000000..fb9404c598a --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/toolset_operations.go @@ -0,0 +1,203 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package agent_api + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/streaming" +) + +// ListToolsets returns all toolsets in the Foundry project. +func (c *AgentClient) ListToolsets(ctx context.Context, apiVersion string) (*ToolsetList, error) { + url := fmt.Sprintf("%s/toolsets?api-version=%s", c.endpoint, apiVersion) + + req, err := runtime.NewRequest(ctx, http.MethodGet, url) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + req.Raw().Header.Set("Foundry-Features", ToolsetFeatureHeader) + + resp, err := c.pipeline.Do(req) + if err != nil { + return nil, fmt.Errorf("HTTP request failed: %w", err) + } + defer resp.Body.Close() + + if !runtime.HasStatusCode(resp, http.StatusOK) { + return nil, runtime.NewResponseError(resp) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + var list ToolsetList + if err := json.Unmarshal(body, &list); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + return &list, nil +} + +// GetToolset retrieves a specific toolset by name. +func (c *AgentClient) GetToolset(ctx context.Context, name, apiVersion string) (*ToolsetObject, error) { + url := fmt.Sprintf("%s/toolsets/%s?api-version=%s", c.endpoint, name, apiVersion) + + req, err := runtime.NewRequest(ctx, http.MethodGet, url) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + req.Raw().Header.Set("Foundry-Features", ToolsetFeatureHeader) + + resp, err := c.pipeline.Do(req) + if err != nil { + return nil, fmt.Errorf("HTTP request failed: %w", err) + } + defer resp.Body.Close() + + if !runtime.HasStatusCode(resp, http.StatusOK) { + return nil, runtime.NewResponseError(resp) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + var toolset ToolsetObject + if err := json.Unmarshal(body, &toolset); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + return &toolset, nil +} + +// CreateToolset creates a new toolset. +func (c *AgentClient) CreateToolset( + ctx context.Context, request *CreateToolsetRequest, apiVersion string, +) (*ToolsetObject, error) { + url := fmt.Sprintf("%s/toolsets?api-version=%s", c.endpoint, apiVersion) + + payload, err := json.Marshal(request) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + req, err := runtime.NewRequest(ctx, http.MethodPost, url) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + req.Raw().Header.Set("Foundry-Features", ToolsetFeatureHeader) + + if err := req.SetBody(streaming.NopCloser(bytes.NewReader(payload)), "application/json"); err != nil { + return nil, fmt.Errorf("failed to set request body: %w", err) + } + + resp, err := c.pipeline.Do(req) + if err != nil { + return nil, fmt.Errorf("HTTP request failed: %w", err) + } + defer resp.Body.Close() + + if !runtime.HasStatusCode(resp, http.StatusOK, http.StatusCreated) { + return nil, runtime.NewResponseError(resp) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + var toolset ToolsetObject + if err := json.Unmarshal(body, &toolset); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + return &toolset, nil +} + +// UpdateToolset updates an existing toolset by name. +func (c *AgentClient) UpdateToolset( + ctx context.Context, name string, request *UpdateToolsetRequest, apiVersion string, +) (*ToolsetObject, error) { + url := fmt.Sprintf("%s/toolsets/%s?api-version=%s", c.endpoint, name, apiVersion) + + payload, err := json.Marshal(request) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + req, err := runtime.NewRequest(ctx, http.MethodPost, url) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + req.Raw().Header.Set("Foundry-Features", ToolsetFeatureHeader) + + if err := req.SetBody(streaming.NopCloser(bytes.NewReader(payload)), "application/json"); err != nil { + return nil, fmt.Errorf("failed to set request body: %w", err) + } + + resp, err := c.pipeline.Do(req) + if err != nil { + return nil, fmt.Errorf("HTTP request failed: %w", err) + } + defer resp.Body.Close() + + if !runtime.HasStatusCode(resp, http.StatusOK) { + return nil, runtime.NewResponseError(resp) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + var toolset ToolsetObject + if err := json.Unmarshal(body, &toolset); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + return &toolset, nil +} + +// DeleteToolset deletes a toolset by name. +func (c *AgentClient) DeleteToolset(ctx context.Context, name, apiVersion string) (*DeleteToolsetResponse, error) { + url := fmt.Sprintf("%s/toolsets/%s?api-version=%s", c.endpoint, name, apiVersion) + + req, err := runtime.NewRequest(ctx, http.MethodDelete, url) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + req.Raw().Header.Set("Foundry-Features", ToolsetFeatureHeader) + + resp, err := c.pipeline.Do(req) + if err != nil { + return nil, fmt.Errorf("HTTP request failed: %w", err) + } + defer resp.Body.Close() + + if !runtime.HasStatusCode(resp, http.StatusOK) { + return nil, runtime.NewResponseError(resp) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + var deleteResponse DeleteToolsetResponse + if err := json.Unmarshal(body, &deleteResponse); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + return &deleteResponse, nil +} From 9dd6530c729456c4c599401078fe7b39bb33c574 Mon Sep 17 00:00:00 2001 From: John Miller Date: Mon, 23 Mar 2026 16:40:20 -0400 Subject: [PATCH 2/5] fix: correct formatting in toolset create command usage message and ensure consistent error code definition --- .../extensions/azure.ai.agents/internal/cmd/toolset_create.go | 2 +- cli/azd/extensions/azure.ai.agents/internal/exterrors/codes.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/toolset_create.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/toolset_create.go index 731628ab0af..06029df3f3e 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/toolset_create.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/toolset_create.go @@ -43,7 +43,7 @@ before overwriting (use --no-prompt to auto-confirm).`, exterrors.CodeInvalidToolsetPayload, "missing required payload file path", "Provide a path to a toolset JSON file, for example:\n"+ - " azd ai agent toolset create path/to/toolset.json", + " azd ai agent toolset create path/to/toolset.json", ) } diff --git a/cli/azd/extensions/azure.ai.agents/internal/exterrors/codes.go b/cli/azd/extensions/azure.ai.agents/internal/exterrors/codes.go index f9fceffc4ed..29abd11fd1f 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/exterrors/codes.go +++ b/cli/azd/extensions/azure.ai.agents/internal/exterrors/codes.go @@ -88,7 +88,7 @@ const ( // Error codes for toolset operations. const ( - CodeToolsetNotFound = "toolset_not_found" + CodeToolsetNotFound = "toolset_not_found" CodeInvalidToolsetPayload = "invalid_toolset_payload" ) From d056c21a3c6a3c35d433203c12e7f9e8ae74428a Mon Sep 17 00:00:00 2001 From: John Miller Date: Mon, 23 Mar 2026 16:48:47 -0400 Subject: [PATCH 3/5] fix: improve error handling for missing AZURE_AI_PROJECT_ENDPOINT in agent endpoint resolution --- .../azure.ai.agents/internal/cmd/agent_context.go | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_context.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_context.go index f2433f04d85..d07951f79cb 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_context.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_context.go @@ -7,6 +7,7 @@ import ( "context" "fmt" + "azureaiagent/internal/exterrors" "azureaiagent/internal/pkg/agents/agent_api" "github.com/Azure/azure-sdk-for-go/sdk/azcore" @@ -87,10 +88,11 @@ func resolveAgentEndpoint(ctx context.Context, accountName string, projectName s Key: "AZURE_AI_PROJECT_ENDPOINT", }) if err != nil || envValue.Value == "" { - return "", fmt.Errorf( - "AZURE_AI_PROJECT_ENDPOINT not found in azd environment '%s'\n\n"+ - "Provide --account-name and --project-name flags, "+ - "or run 'azd ai agent init' to configure the endpoint", envResponse.Environment.Name) + return "", exterrors.Dependency( + exterrors.CodeMissingAiProjectEndpoint, + fmt.Sprintf("AZURE_AI_PROJECT_ENDPOINT not found in azd environment '%s'", envResponse.Environment.Name), + "run 'azd provision' to provision your Azure resources and set the endpoint", + ) } return envValue.Value, nil From a7c3a2e4a900fe59e29bcac5fd8173669c03fc2c Mon Sep 17 00:00:00 2001 From: John Miller Date: Mon, 23 Mar 2026 23:12:22 -0400 Subject: [PATCH 4/5] feat: add toolbox management commands for Azure AI agents - Implemented `toolbox delete` command to remove a toolbox from the Foundry project with confirmation prompt. - Added `toolbox list` command to display all toolboxes in the current Azure AI Foundry project, supporting both table and JSON output formats. - Created `toolbox show` command to display detailed information about a specific toolbox, including its properties and tools. - Developed corresponding unit tests for toolbox commands to ensure functionality and correctness. - Introduced new data structures and API operations for managing toolboxes, including creation, deletion, and retrieval. - Updated project configuration to include toolbox dependencies, allowing agents to specify required toolboxes in their configuration. - Enhanced error handling and messaging for toolbox operations to improve user experience. --- .../azure.ai.agents/internal/cmd/init.go | 64 ++++++++----- .../azure.ai.agents/internal/cmd/listen.go | 32 +++++++ .../azure.ai.agents/internal/cmd/root.go | 2 +- .../azure.ai.agents/internal/cmd/toolbox.go | 26 +++++ .../{toolset_create.go => toolbox_create.go} | 92 ++++++++---------- .../{toolset_delete.go => toolbox_delete.go} | 30 +++--- .../cmd/{toolset_list.go => toolbox_list.go} | 36 +++---- .../cmd/{toolset_show.go => toolbox_show.go} | 49 +++++----- .../cmd/{toolset_test.go => toolbox_test.go} | 95 ++++++++++--------- .../azure.ai.agents/internal/cmd/toolset.go | 26 ----- .../internal/exterrors/codes.go | 18 ++-- .../{toolset_models.go => toolbox_models.go} | 30 +++--- ...et_operations.go => toolbox_operations.go} | 64 ++++++------- .../internal/pkg/agents/agent_yaml/parse.go | 6 ++ .../internal/pkg/agents/agent_yaml/yaml.go | 12 ++- .../internal/project/config.go | 30 ++++++ .../internal/project/service_target_agent.go | 75 ++++++++++++++- .../schemas/azure.ai.agent.json | 20 ++++ 18 files changed, 441 insertions(+), 266 deletions(-) create mode 100644 cli/azd/extensions/azure.ai.agents/internal/cmd/toolbox.go rename cli/azd/extensions/azure.ai.agents/internal/cmd/{toolset_create.go => toolbox_create.go} (60%) rename cli/azd/extensions/azure.ai.agents/internal/cmd/{toolset_delete.go => toolbox_delete.go} (71%) rename cli/azd/extensions/azure.ai.agents/internal/cmd/{toolset_list.go => toolbox_list.go} (67%) rename cli/azd/extensions/azure.ai.agents/internal/cmd/{toolset_show.go => toolbox_show.go} (66%) rename cli/azd/extensions/azure.ai.agents/internal/cmd/{toolset_test.go => toolbox_test.go} (52%) delete mode 100644 cli/azd/extensions/azure.ai.agents/internal/cmd/toolset.go rename cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/{toolset_models.go => toolbox_models.go} (71%) rename cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/{toolset_operations.go => toolbox_operations.go} (72%) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go index e4fbf460ae2..a927be5f56d 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -1120,35 +1120,54 @@ func (a *InitAction) addToProject(ctx context.Context, targetDir string, agentMa var agentConfig = project.ServiceTargetAgentConfig{} resourceDetails := []project.Resource{} + toolboxDetails := []project.Toolbox{} switch agentDef.Kind { case agent_yaml.AgentKindHosted: - // Handle tool resources that require connection names + // Handle tool resources that require connection names or toolbox dependencies if agentManifest.Resources != nil { for _, resource := range agentManifest.Resources { - // Try to cast to ToolResource - if toolResource, ok := resource.(agent_yaml.ToolResource); ok { - // Check if this is a resource that requires a connection name - if toolResource.Id == "bing_grounding" || toolResource.Id == "azure_ai_search" { - // Prompt the user for a connection name - resp, err := a.azdClient.Prompt().Prompt(ctx, &azdext.PromptRequest{ - Options: &azdext.PromptOptions{ - Message: fmt.Sprintf("Enter a connection name for adding the resource %s to your Microsoft Foundry project", toolResource.Id), - IgnoreHintKeys: true, - DefaultValue: toolResource.Id, - }, - }) - if err != nil { - return fmt.Errorf("prompting for connection name for %s: %w", toolResource.Id, err) - } + switch res := resource.(type) { + case agent_yaml.ToolResource: + // Prompt the user for a connection name + resp, err := a.azdClient.Prompt().Prompt(ctx, &azdext.PromptRequest{ + Options: &azdext.PromptOptions{ + Message: fmt.Sprintf("Enter a connection name for adding the resource %s to your Microsoft Foundry project", res.Id), + IgnoreHintKeys: true, + DefaultValue: res.Id, + }, + }) + if err != nil { + return fmt.Errorf("prompting for connection name for %s: %w", res.Id, err) + } - // Add to resource details - resourceDetails = append(resourceDetails, project.Resource{ - Resource: toolResource.Id, - ConnectionName: resp.Value, - }) + resourceDetails = append(resourceDetails, project.Resource{ + Resource: res.Id, + ConnectionName: resp.Value, + }) + + case agent_yaml.ToolboxResource: + toolbox := project.Toolbox{ + Name: res.Id, } + + if res.Options != nil { + if desc, ok := res.Options["description"].(string); ok { + toolbox.Description = desc + } + if tools, ok := res.Options["tools"].([]any); ok { + for _, t := range tools { + toolJSON, err := json.Marshal(t) + if err != nil { + return fmt.Errorf("failed to marshal tool definition for toolbox %s: %w", + res.Id, err) + } + toolbox.Tools = append(toolbox.Tools, toolJSON) + } + } + } + + toolboxDetails = append(toolboxDetails, toolbox) } - // Skip the resource if the cast fails } } @@ -1162,6 +1181,7 @@ func (a *InitAction) addToProject(ctx context.Context, targetDir string, agentMa agentConfig.Deployments = a.deploymentDetails agentConfig.Resources = resourceDetails + agentConfig.Toolboxes = toolboxDetails // Detect startup command from the project source directory startupCmd, err := resolveStartupCommandForInit(ctx, a.azdClient, a.projectConfig.Path, targetDir, a.flags.NoPrompt) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go index a4f8b5ecdee..acbd5f86500 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go @@ -152,6 +152,12 @@ func envUpdate(ctx context.Context, azdClient *azdext.AzdClient, azdProject *azd } } + if len(foundryAgentConfig.Toolboxes) > 0 { + if err := toolboxEnvUpdate(ctx, foundryAgentConfig.Toolboxes, azdClient, currentEnvResponse.Environment.Name); err != nil { + return err + } + } + return nil } @@ -219,6 +225,32 @@ func resourcesEnvUpdate(ctx context.Context, resources []project.Resource, azdCl return setEnvVar(ctx, azdClient, envName, "AI_PROJECT_DEPENDENT_RESOURCES", escapedJsonString) } +func toolboxEnvUpdate(ctx context.Context, toolboxes []project.Toolbox, azdClient *azdext.AzdClient, envName string) error { + // Resolve the project endpoint to construct MCP URLs + envResp, err := azdClient.Environment().GetValue(ctx, &azdext.GetEnvRequest{ + EnvName: envName, + Key: "AZURE_AI_PROJECT_ENDPOINT", + }) + if err != nil { + return fmt.Errorf("failed to get AZURE_AI_PROJECT_ENDPOINT: %w", err) + } + + projectEndpoint := envResp.Value + if projectEndpoint == "" { + return fmt.Errorf("AZURE_AI_PROJECT_ENDPOINT not set; required to resolve toolbox MCP endpoints") + } + + for _, tb := range toolboxes { + mcpEndpoint := project.ToolboxMcpEndpoint(projectEndpoint, tb.Name) + envVar := project.ToolboxNameToEnvVar(tb.Name) + "_MCP_ENDPOINT" + if err := setEnvVar(ctx, azdClient, envName, envVar, mcpEndpoint); err != nil { + return err + } + } + + return nil +} + func containerAgentHandling(ctx context.Context, azdClient *azdext.AzdClient, project *azdext.ProjectConfig, svc *azdext.ServiceConfig) error { servicePath := svc.RelativePath fullPath := filepath.Join(project.Path, servicePath) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/root.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/root.go index bef87b36c30..f7aa88642a4 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/root.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/root.go @@ -78,7 +78,7 @@ func NewRootCommand() *cobra.Command { rootCmd.AddCommand(newShowCommand()) rootCmd.AddCommand(newMonitorCommand()) rootCmd.AddCommand(newFilesCommand()) - rootCmd.AddCommand(newToolsetCommand()) + rootCmd.AddCommand(newToolboxCommand()) return rootCmd } diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/toolbox.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/toolbox.go new file mode 100644 index 00000000000..ec52549fc26 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/toolbox.go @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "github.com/spf13/cobra" +) + +func newToolboxCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "toolbox", + Short: "Manage Foundry toolboxes.", + Long: `Manage Foundry toolboxes in the current Azure AI Foundry project. + +Toolboxes are named collections of tools (MCP servers, OpenAPI endpoints, first-party tools) +exposed through a unified MCP-compatible endpoint with platform-managed auth.`, + } + + cmd.AddCommand(newToolboxListCommand()) + cmd.AddCommand(newToolboxShowCommand()) + cmd.AddCommand(newToolboxCreateCommand()) + cmd.AddCommand(newToolboxDeleteCommand()) + + return cmd +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/toolset_create.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/toolbox_create.go similarity index 60% rename from cli/azd/extensions/azure.ai.agents/internal/cmd/toolset_create.go rename to cli/azd/extensions/azure.ai.agents/internal/cmd/toolbox_create.go index 06029df3f3e..4c7c2c4f49c 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/toolset_create.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/toolbox_create.go @@ -8,10 +8,10 @@ import ( "errors" "fmt" "os" - "strings" "azureaiagent/internal/exterrors" "azureaiagent/internal/pkg/agents/agent_api" + "azureaiagent/internal/project" "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/azure/azure-dev/cli/azd/pkg/azdext" @@ -19,20 +19,20 @@ import ( "github.com/spf13/cobra" ) -func newToolsetCreateCommand() *cobra.Command { +func newToolboxCreateCommand() *cobra.Command { cmd := &cobra.Command{ - Use: "create ", - Short: "Create a toolset in the Foundry project.", + Use: "create ", + Short: "Create a toolbox in the Foundry project.", Long: `Create a new toolset from a JSON payload file. The payload file must contain a JSON object with at least "name" and "tools" fields. -If a toolset with the same name already exists, you will be prompted to confirm +If a toolbox with the same name already exists, you will be prompted to confirm before overwriting (use --no-prompt to auto-confirm).`, - Example: ` # Create a toolset from a JSON file - azd ai agent toolset create toolset.json + Example: ` # Create a toolbox from a JSON file + azd ai agent toolbox create toolbox.json # Create with auto-confirm for scripting - azd ai agent toolset create toolset.json --no-prompt`, + azd ai agent toolbox create toolbox.json --no-prompt`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { ctx := azdext.WithAccessToken(cmd.Context()) @@ -40,10 +40,10 @@ before overwriting (use --no-prompt to auto-confirm).`, if len(args) == 0 { return exterrors.Validation( - exterrors.CodeInvalidToolsetPayload, + exterrors.CodeInvalidToolboxPayload, "missing required payload file path", - "Provide a path to a toolset JSON file, for example:\n"+ - " azd ai agent toolset create path/to/toolset.json", + "Provide a path to a toolbox JSON file, for example:\n"+ + " azd ai agent toolbox create path/to/toolbox.json", ) } @@ -53,16 +53,16 @@ before overwriting (use --no-prompt to auto-confirm).`, data, err := os.ReadFile(payloadPath) //nolint:gosec // G304: path is from user CLI arg, validated below if err != nil { return exterrors.Validation( - exterrors.CodeInvalidToolsetPayload, + exterrors.CodeInvalidToolboxPayload, fmt.Sprintf("failed to read payload file '%s': %s", payloadPath, err), "Check that the file path is correct and the file is readable", ) } - var createReq agent_api.CreateToolsetRequest + var createReq agent_api.CreateToolboxRequest if err := json.Unmarshal(data, &createReq); err != nil { return exterrors.Validation( - exterrors.CodeInvalidToolsetPayload, + exterrors.CodeInvalidToolboxPayload, fmt.Sprintf("failed to parse payload file '%s': %s", payloadPath, err), "Ensure the file contains valid JSON with 'name' and 'tools' fields", ) @@ -70,15 +70,15 @@ before overwriting (use --no-prompt to auto-confirm).`, if createReq.Name == "" { return exterrors.Validation( - exterrors.CodeInvalidToolsetPayload, - "toolset payload is missing required 'name' field", + exterrors.CodeInvalidToolboxPayload, + "toolbox payload is missing required 'name' field", "Add a 'name' field to the JSON payload", ) } if len(createReq.Tools) == 0 { return exterrors.Validation( - exterrors.CodeInvalidToolsetPayload, - "toolset payload is missing required 'tools' field or tools array is empty", + exterrors.CodeInvalidToolboxPayload, + "toolbox payload is missing required 'tools' field or tools array is empty", "Add a 'tools' array with at least one tool definition", ) } @@ -99,8 +99,8 @@ before overwriting (use --no-prompt to auto-confirm).`, client := agent_api.NewAgentClient(endpoint, credential) - // Check if toolset already exists - existing, err := client.GetToolset(ctx, createReq.Name, agent_api.ToolsetAPIVersion) + // Check if toolbox already exists + existing, err := client.GetToolbox(ctx, createReq.Name, agent_api.ToolboxAPIVersion) if err == nil && existing != nil { // Toolset exists — prompt for overwrite confirmation if !rootFlags.NoPrompt { @@ -113,58 +113,58 @@ before overwriting (use --no-prompt to auto-confirm).`, resp, promptErr := azdClient.Prompt().Confirm(ctx, &azdext.ConfirmRequest{ Options: &azdext.ConfirmOptions{ Message: fmt.Sprintf( - "Toolset '%s' already exists with %d tool(s). Overwrite?", + "Toolbox '%s' already exists with %d tool(s). Overwrite?", existing.Name, len(existing.Tools), ), }, }) if promptErr != nil { if exterrors.IsCancellation(promptErr) { - return exterrors.Cancelled("toolset creation cancelled") + return exterrors.Cancelled("toolbox creation cancelled") } return fmt.Errorf("failed to prompt for confirmation: %w", promptErr) } if !*resp.Value { - fmt.Println("Toolset creation cancelled.") + fmt.Println("toolbox creation cancelled.") return nil } } - // Update the existing toolset - updateReq := &agent_api.UpdateToolsetRequest{ + // Update the existing toolbox + updateReq := &agent_api.UpdateToolboxRequest{ Description: createReq.Description, Metadata: createReq.Metadata, Tools: createReq.Tools, } - toolset, updateErr := client.UpdateToolset(ctx, createReq.Name, updateReq, agent_api.ToolsetAPIVersion) + toolbox, updateErr := client.UpdateToolbox(ctx, createReq.Name, updateReq, agent_api.ToolboxAPIVersion) if updateErr != nil { - return exterrors.ServiceFromAzure(updateErr, exterrors.OpUpdateToolset) + return exterrors.ServiceFromAzure(updateErr, exterrors.OpUpdateToolbox) } - mcpEndpoint := fmt.Sprintf("%s/toolsets/%s/mcp", endpoint, toolset.Name) - fmt.Printf("Toolset '%s' updated successfully (%d tool(s)).\n", toolset.Name, len(toolset.Tools)) + mcpEndpoint := project.ToolboxMcpEndpoint(endpoint, toolbox.Name) + fmt.Printf("Toolbox '%s' updated successfully (%d tool(s)).\n", toolbox.Name, len(toolbox.Tools)) fmt.Printf("MCP Endpoint: %s\n", mcpEndpoint) - printMcpEnvTip(toolset.Name, mcpEndpoint) + printMcpEnvTip(toolbox.Name, mcpEndpoint) return nil } // Check if the error is a 404 (not found) — proceed with create var respErr *azcore.ResponseError if err != nil && !(errors.As(err, &respErr) && respErr.StatusCode == 404) { - return exterrors.ServiceFromAzure(err, exterrors.OpGetToolset) + return exterrors.ServiceFromAzure(err, exterrors.OpGetToolbox) } - // Create new toolset - toolset, createErr := client.CreateToolset(ctx, &createReq, agent_api.ToolsetAPIVersion) + // Create new toolbox + toolbox, createErr := client.CreateToolbox(ctx, &createReq, agent_api.ToolboxAPIVersion) if createErr != nil { - return exterrors.ServiceFromAzure(createErr, exterrors.OpCreateToolset) + return exterrors.ServiceFromAzure(createErr, exterrors.OpCreateToolbox) } - mcpEndpoint := fmt.Sprintf("%s/toolsets/%s/mcp", endpoint, toolset.Name) - fmt.Printf("Toolset '%s' created successfully (%d tool(s)).\n", toolset.Name, len(toolset.Tools)) + mcpEndpoint := project.ToolboxMcpEndpoint(endpoint, toolbox.Name) + fmt.Printf("Toolbox '%s' created successfully (%d tool(s)).\n", toolbox.Name, len(toolbox.Tools)) fmt.Printf("MCP Endpoint: %s\n", mcpEndpoint) - printMcpEnvTip(toolset.Name, mcpEndpoint) + printMcpEnvTip(toolbox.Name, mcpEndpoint) return nil }, } @@ -172,22 +172,8 @@ before overwriting (use --no-prompt to auto-confirm).`, return cmd } -// toolsetNameToEnvVar converts a toolset name to an environment variable name -// by upper-casing and replacing non-alphanumeric characters with underscores. -func toolsetNameToEnvVar(name string) string { - var b strings.Builder - for _, r := range strings.ToUpper(name) { - if (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') { - b.WriteRune(r) - } else { - b.WriteByte('_') - } - } - return b.String() -} - -func printMcpEnvTip(toolsetName, mcpEndpoint string) { - envVar := toolsetNameToEnvVar(toolsetName) + "_MCP_ENDPOINT" +func printMcpEnvTip(toolboxName, mcpEndpoint string) { + envVar := project.ToolboxNameToEnvVar(toolboxName) + "_MCP_ENDPOINT" fmt.Println() fmt.Println(output.WithHintFormat( "Hint: Store the endpoint in your azd environment so your agent code can reference it:")) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/toolset_delete.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/toolbox_delete.go similarity index 71% rename from cli/azd/extensions/azure.ai.agents/internal/cmd/toolset_delete.go rename to cli/azd/extensions/azure.ai.agents/internal/cmd/toolbox_delete.go index 6c1265e1325..3c05cc2f1be 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/toolset_delete.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/toolbox_delete.go @@ -15,18 +15,18 @@ import ( "github.com/spf13/cobra" ) -func newToolsetDeleteCommand() *cobra.Command { +func newToolboxDeleteCommand() *cobra.Command { cmd := &cobra.Command{ Use: "delete ", - Short: "Delete a toolset from the Foundry project.", - Long: `Delete a toolset by name from the current Azure AI Foundry project. + Short: "Delete a toolbox from the Foundry project.", + Long: `Delete a toolbox by name from the current Azure AI Foundry project. You will be prompted to confirm before deleting (use --no-prompt to auto-confirm).`, - Example: ` # Delete a toolset (with confirmation prompt) - azd ai agent toolset delete my-toolset + Example: ` # Delete a toolbox (with confirmation prompt) + azd ai agent toolbox delete my-toolbox # Delete without prompting - azd ai agent toolset delete my-toolset --no-prompt`, + azd ai agent toolbox delete my-toolbox --no-prompt`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { name := args[0] @@ -57,37 +57,37 @@ You will be prompted to confirm before deleting (use --no-prompt to auto-confirm resp, promptErr := azdClient.Prompt().Confirm(ctx, &azdext.ConfirmRequest{ Options: &azdext.ConfirmOptions{ - Message: fmt.Sprintf("Are you sure you want to delete toolset '%s'?", name), + Message: fmt.Sprintf("Are you sure you want to delete Toolbox '%s'?", name), }, }) if promptErr != nil { if exterrors.IsCancellation(promptErr) { - return exterrors.Cancelled("toolset deletion cancelled") + return exterrors.Cancelled("toolbox deletion cancelled") } return fmt.Errorf("failed to prompt for confirmation: %w", promptErr) } if !*resp.Value { - fmt.Println("Toolset deletion cancelled.") + fmt.Println("toolbox deletion cancelled.") return nil } } client := agent_api.NewAgentClient(endpoint, credential) - _, err = client.DeleteToolset(ctx, name, agent_api.ToolsetAPIVersion) + _, err = client.DeleteToolbox(ctx, name, agent_api.ToolboxAPIVersion) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == 404 { return exterrors.Validation( - exterrors.CodeToolsetNotFound, - fmt.Sprintf("toolset '%s' not found", name), - "Run 'azd ai agent toolset list' to see available toolsets", + exterrors.CodeToolboxNotFound, + fmt.Sprintf("Toolbox '%s' not found", name), + "Run 'azd ai agent toolbox list' to see available toolboxes", ) } - return exterrors.ServiceFromAzure(err, exterrors.OpDeleteToolset) + return exterrors.ServiceFromAzure(err, exterrors.OpDeleteToolbox) } - fmt.Printf("Toolset '%s' deleted successfully.\n", name) + fmt.Printf("Toolbox '%s' deleted successfully.\n", name) return nil }, } diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/toolset_list.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/toolbox_list.go similarity index 67% rename from cli/azd/extensions/azure.ai.agents/internal/cmd/toolset_list.go rename to cli/azd/extensions/azure.ai.agents/internal/cmd/toolbox_list.go index 14d680a3dbc..cfba2a3bb21 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/toolset_list.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/toolbox_list.go @@ -18,25 +18,25 @@ import ( "github.com/spf13/cobra" ) -type toolsetListFlags struct { +type toolboxListFlags struct { output string } -func newToolsetListCommand() *cobra.Command { - flags := &toolsetListFlags{} +func newToolboxListCommand() *cobra.Command { + flags := &toolboxListFlags{} cmd := &cobra.Command{ Use: "list", - Short: "List all toolsets in the Foundry project.", - Long: `List all toolsets in the current Azure AI Foundry project. + Short: "List all toolboxes in the Foundry project.", + Long: `List all toolboxes in the current Azure AI Foundry project. Displays the name, description, number of tools, and creation time -for each toolset. Requires AZURE_AI_PROJECT_ENDPOINT in the azd environment.`, - Example: ` # List toolsets in table format (default) - azd ai agent toolset list +for each toolbox. Requires AZURE_AI_PROJECT_ENDPOINT in the azd environment.`, + Example: ` # List toolboxes in table format (default) + azd ai agent toolbox list - # List toolsets as JSON - azd ai agent toolset list --output json`, + # List toolboxes as JSON + azd ai agent toolbox list --output json`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { ctx := azdext.WithAccessToken(cmd.Context()) @@ -57,16 +57,16 @@ for each toolset. Requires AZURE_AI_PROJECT_ENDPOINT in the azd environment.`, } client := agent_api.NewAgentClient(endpoint, credential) - list, err := client.ListToolsets(ctx, agent_api.ToolsetAPIVersion) + list, err := client.ListToolboxes(ctx, agent_api.ToolboxAPIVersion) if err != nil { - return exterrors.ServiceFromAzure(err, exterrors.OpListToolsets) + return exterrors.ServiceFromAzure(err, exterrors.OpListToolboxes) } switch flags.output { case "json": - return printToolsetListJSON(list) + return printToolboxListJSON(list) default: - return printToolsetListTable(ctx, list) + return printToolboxListTable(ctx, list) } }, } @@ -76,18 +76,18 @@ for each toolset. Requires AZURE_AI_PROJECT_ENDPOINT in the azd environment.`, return cmd } -func printToolsetListJSON(list *agent_api.ToolsetList) error { +func printToolboxListJSON(list *agent_api.ToolboxList) error { jsonBytes, err := json.MarshalIndent(list, "", " ") if err != nil { - return fmt.Errorf("failed to marshal toolset list to JSON: %w", err) + return fmt.Errorf("failed to marshal toolbox list to JSON: %w", err) } fmt.Println(string(jsonBytes)) return nil } -func printToolsetListTable(_ context.Context, list *agent_api.ToolsetList) error { +func printToolboxListTable(_ context.Context, list *agent_api.ToolboxList) error { if len(list.Data) == 0 { - fmt.Println("No toolsets found.") + fmt.Println("No toolboxes found.") return nil } diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/toolset_show.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/toolbox_show.go similarity index 66% rename from cli/azd/extensions/azure.ai.agents/internal/cmd/toolset_show.go rename to cli/azd/extensions/azure.ai.agents/internal/cmd/toolbox_show.go index c24b77fe548..b2f8d97dae1 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/toolset_show.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/toolbox_show.go @@ -12,30 +12,31 @@ import ( "azureaiagent/internal/exterrors" "azureaiagent/internal/pkg/agents/agent_api" + "azureaiagent/internal/project" "github.com/azure/azure-dev/cli/azd/pkg/azdext" "github.com/spf13/cobra" ) -type toolsetShowFlags struct { +type toolboxShowFlags struct { output string } -func newToolsetShowCommand() *cobra.Command { - flags := &toolsetShowFlags{} +func newToolboxShowCommand() *cobra.Command { + flags := &toolboxShowFlags{} cmd := &cobra.Command{ Use: "show ", - Short: "Show details of a toolset.", - Long: `Show details of a Foundry toolset by name. + Short: "Show details of a toolbox.", + Long: `Show details of a Foundry toolbox by name. -Displays the toolset's properties, included tools, and the MCP endpoint URL -that can be used to connect an agent to the toolset.`, - Example: ` # Show toolset details as JSON (default) - azd ai agent toolset show my-toolset +Displays the toolbox's properties, included tools, and the MCP endpoint URL +that can be used to connect an agent to the toolbox.`, + Example: ` # Show toolbox details as JSON (default) + azd ai agent toolbox show my-toolbox - # Show toolset details as a table - azd ai agent toolset show my-toolset --output table`, + # Show toolbox details as a table + azd ai agent toolbox show my-toolbox --output table`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { name := args[0] @@ -57,18 +58,18 @@ that can be used to connect an agent to the toolset.`, } client := agent_api.NewAgentClient(endpoint, credential) - toolset, err := client.GetToolset(ctx, name, agent_api.ToolsetAPIVersion) + toolbox, err := client.GetToolbox(ctx, name, agent_api.ToolboxAPIVersion) if err != nil { - return exterrors.ServiceFromAzure(err, exterrors.OpGetToolset) + return exterrors.ServiceFromAzure(err, exterrors.OpGetToolbox) } - mcpEndpoint := fmt.Sprintf("%s/toolsets/%s/mcp", endpoint, name) + mcpEndpoint := project.ToolboxMcpEndpoint(endpoint, name) switch flags.output { case "table": - return printToolsetShowTable(toolset, mcpEndpoint) + return printToolboxShowTable(toolbox, mcpEndpoint) default: - return printToolsetShowJSON(toolset, mcpEndpoint) + return printToolboxShowJSON(toolbox, mcpEndpoint) } }, } @@ -78,27 +79,27 @@ that can be used to connect an agent to the toolset.`, return cmd } -// toolsetShowOutput wraps the toolset object with the computed MCP endpoint for JSON output. -type toolsetShowOutput struct { - agent_api.ToolsetObject +// toolboxShowOutput wraps the toolbox object with the computed MCP endpoint for JSON output. +type toolboxShowOutput struct { + agent_api.ToolboxObject MCPEndpoint string `json:"mcp_endpoint"` } -func printToolsetShowJSON(toolset *agent_api.ToolsetObject, mcpEndpoint string) error { - output := toolsetShowOutput{ - ToolsetObject: *toolset, +func printToolboxShowJSON(toolset *agent_api.ToolboxObject, mcpEndpoint string) error { + output := toolboxShowOutput{ + ToolboxObject: *toolset, MCPEndpoint: mcpEndpoint, } jsonBytes, err := json.MarshalIndent(output, "", " ") if err != nil { - return fmt.Errorf("failed to marshal toolset to JSON: %w", err) + return fmt.Errorf("failed to marshal toolbox to JSON: %w", err) } fmt.Println(string(jsonBytes)) return nil } -func printToolsetShowTable(toolset *agent_api.ToolsetObject, mcpEndpoint string) error { +func printToolboxShowTable(toolset *agent_api.ToolboxObject, mcpEndpoint string) error { w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) fmt.Fprintln(w, "FIELD\tVALUE") fmt.Fprintln(w, "-----\t-----") diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/toolset_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/toolbox_test.go similarity index 52% rename from cli/azd/extensions/azure.ai.agents/internal/cmd/toolset_test.go rename to cli/azd/extensions/azure.ai.agents/internal/cmd/toolbox_test.go index b1f46120e04..0962d623c35 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/toolset_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/toolbox_test.go @@ -8,13 +8,14 @@ import ( "testing" "azureaiagent/internal/pkg/agents/agent_api" + "azureaiagent/internal/project" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func TestToolsetCommand_HasSubcommands(t *testing.T) { - cmd := newToolsetCommand() +func TestToolboxCommand_HasSubcommands(t *testing.T) { + cmd := newToolboxCommand() subcommands := cmd.Commands() names := make([]string, len(subcommands)) @@ -28,66 +29,66 @@ func TestToolsetCommand_HasSubcommands(t *testing.T) { assert.Contains(t, names, "delete") } -func TestToolsetListCommand_DefaultOutputFormat(t *testing.T) { - cmd := newToolsetListCommand() +func TestToolboxListCommand_DefaultOutputFormat(t *testing.T) { + cmd := newToolboxListCommand() output, _ := cmd.Flags().GetString("output") assert.Equal(t, "table", output) } -func TestToolsetListCommand_HasFlags(t *testing.T) { - cmd := newToolsetListCommand() +func TestToolboxListCommand_HasFlags(t *testing.T) { + cmd := newToolboxListCommand() f := cmd.Flags().Lookup("output") require.NotNil(t, f, "expected flag 'output'") assert.Equal(t, "o", f.Shorthand) } -func TestToolsetShowCommand_RequiresArg(t *testing.T) { - cmd := newToolsetShowCommand() +func TestToolboxShowCommand_RequiresArg(t *testing.T) { + cmd := newToolboxShowCommand() cmd.SetArgs([]string{}) err := cmd.Execute() assert.Error(t, err) } -func TestToolsetShowCommand_DefaultOutputFormat(t *testing.T) { - cmd := newToolsetShowCommand() +func TestToolboxShowCommand_DefaultOutputFormat(t *testing.T) { + cmd := newToolboxShowCommand() output, _ := cmd.Flags().GetString("output") assert.Equal(t, "json", output) } -func TestToolsetShowCommand_HasFlags(t *testing.T) { - cmd := newToolsetShowCommand() +func TestToolboxShowCommand_HasFlags(t *testing.T) { + cmd := newToolboxShowCommand() f := cmd.Flags().Lookup("output") require.NotNil(t, f, "expected flag 'output'") assert.Equal(t, "o", f.Shorthand) } -func TestToolsetCreateCommand_AcceptsOneArg(t *testing.T) { - cmd := newToolsetCreateCommand() +func TestToolboxCreateCommand_AcceptsOneArg(t *testing.T) { + cmd := newToolboxCreateCommand() assert.NotNil(t, cmd.Args) } -func TestToolsetDeleteCommand_RequiresArg(t *testing.T) { - cmd := newToolsetDeleteCommand() +func TestToolboxDeleteCommand_RequiresArg(t *testing.T) { + cmd := newToolboxDeleteCommand() cmd.SetArgs([]string{}) err := cmd.Execute() assert.Error(t, err) } -func TestToolsetNameToEnvVar(t *testing.T) { +func TestToolboxNameToEnvVar(t *testing.T) { tests := []struct { name string in string want string }{ - {"simple", "my-toolset", "MY_TOOLSET"}, - {"already upper", "MY_TOOLSET", "MY_TOOLSET"}, - {"dots and spaces", "my.toolset name", "MY_TOOLSET_NAME"}, + {"simple", "my-toolbox", "MY_TOOLBOX"}, + {"already upper", "MY_TOOLBOX", "MY_TOOLBOX"}, + {"dots and spaces", "my.toolbox name", "MY_TOOLBOX_NAME"}, {"numeric", "tools123", "TOOLS123"}, {"empty", "", ""}, {"special chars", "a@b#c", "A_B_C"}, @@ -95,30 +96,30 @@ func TestToolsetNameToEnvVar(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := toolsetNameToEnvVar(tt.in) + got := project.ToolboxNameToEnvVar(tt.in) assert.Equal(t, tt.want, got) }) } } -func TestPrintToolsetListTable_Empty(t *testing.T) { - list := &agent_api.ToolsetList{} - err := printToolsetListTable(t.Context(), list) +func TestPrintToolboxListTable_Empty(t *testing.T) { + list := &agent_api.ToolboxList{} + err := printToolboxListTable(t.Context(), list) require.NoError(t, err) } -func TestPrintToolsetListJSON_Empty(t *testing.T) { - list := &agent_api.ToolsetList{} - err := printToolsetListJSON(list) +func TestPrintToolboxListJSON_Empty(t *testing.T) { + list := &agent_api.ToolboxList{} + err := printToolboxListJSON(list) require.NoError(t, err) } -func TestPrintToolsetListTable_WithData(t *testing.T) { - list := &agent_api.ToolsetList{ - Data: []agent_api.ToolsetObject{ +func TestPrintToolboxListTable_WithData(t *testing.T) { + list := &agent_api.ToolboxList{ + Data: []agent_api.ToolboxObject{ { - Name: "test-toolset", - Description: "A test toolset", + Name: "test-toolbox", + Description: "A Test toolbox", Tools: []json.RawMessage{json.RawMessage(`{"type":"mcp_server"}`)}, CreatedAt: 1700000000, }, @@ -130,40 +131,40 @@ func TestPrintToolsetListTable_WithData(t *testing.T) { }, } - err := printToolsetListTable(t.Context(), list) + err := printToolboxListTable(t.Context(), list) require.NoError(t, err) } -func TestPrintToolsetListJSON_WithData(t *testing.T) { - list := &agent_api.ToolsetList{ - Data: []agent_api.ToolsetObject{ +func TestPrintToolboxListJSON_WithData(t *testing.T) { + list := &agent_api.ToolboxList{ + Data: []agent_api.ToolboxObject{ { - Name: "test-toolset", + Name: "test-toolbox", Tools: []json.RawMessage{json.RawMessage(`{"type":"mcp_server"}`)}, }, }, } - err := printToolsetListJSON(list) + err := printToolboxListJSON(list) require.NoError(t, err) } -func TestPrintToolsetShowJSON(t *testing.T) { - toolset := &agent_api.ToolsetObject{ - Name: "my-toolset", +func TestPrintToolboxShowJSON(t *testing.T) { + toolbox := &agent_api.ToolboxObject{ + Name: "my-toolbox", ID: "ts-123", Tools: []json.RawMessage{json.RawMessage(`{"type":"openapi"}`)}, } - err := printToolsetShowJSON(toolset, "https://example.com/toolsets/my-toolset/mcp") + err := printToolboxShowJSON(toolbox, "https://example.com/toolsets/my-toolbox/mcp") require.NoError(t, err) } -func TestPrintToolsetShowTable(t *testing.T) { - toolset := &agent_api.ToolsetObject{ - Name: "my-toolset", +func TestPrintToolboxShowTable(t *testing.T) { + toolbox := &agent_api.ToolboxObject{ + Name: "my-toolbox", ID: "ts-123", - Description: "Test toolset", + Description: "Test toolbox", CreatedAt: 1700000000, UpdatedAt: 1700001000, Tools: []json.RawMessage{ @@ -172,6 +173,6 @@ func TestPrintToolsetShowTable(t *testing.T) { }, } - err := printToolsetShowTable(toolset, "https://example.com/toolsets/my-toolset/mcp") + err := printToolboxShowTable(toolbox, "https://example.com/toolsets/my-toolbox/mcp") require.NoError(t, err) } diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/toolset.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/toolset.go deleted file mode 100644 index 1be38814e34..00000000000 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/toolset.go +++ /dev/null @@ -1,26 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -package cmd - -import ( - "github.com/spf13/cobra" -) - -func newToolsetCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "toolset", - Short: "Manage Foundry toolsets.", - Long: `Manage Foundry toolsets in the current Azure AI Foundry project. - -Toolsets are named collections of tools (MCP servers, OpenAPI endpoints, first-party tools) -exposed through a unified MCP-compatible endpoint with platform-managed auth.`, - } - - cmd.AddCommand(newToolsetListCommand()) - cmd.AddCommand(newToolsetShowCommand()) - cmd.AddCommand(newToolsetCreateCommand()) - cmd.AddCommand(newToolsetDeleteCommand()) - - return cmd -} diff --git a/cli/azd/extensions/azure.ai.agents/internal/exterrors/codes.go b/cli/azd/extensions/azure.ai.agents/internal/exterrors/codes.go index 29abd11fd1f..27c60a8bf1b 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/exterrors/codes.go +++ b/cli/azd/extensions/azure.ai.agents/internal/exterrors/codes.go @@ -86,19 +86,19 @@ const ( CodeInvalidFilePath = "invalid_file_path" ) -// Error codes for toolset operations. +// Error codes for toolbox operations. const ( - CodeToolsetNotFound = "toolset_not_found" - CodeInvalidToolsetPayload = "invalid_toolset_payload" + CodeToolboxNotFound = "toolbox_not_found" + CodeInvalidToolboxPayload = "invalid_toolbox_payload" ) -// Operation names for toolset [ServiceFromAzure] errors. +// Operation names for toolbox [ServiceFromAzure] errors. const ( - OpListToolsets = "list_toolsets" - OpGetToolset = "get_toolset" - OpCreateToolset = "create_toolset" - OpUpdateToolset = "update_toolset" - OpDeleteToolset = "delete_toolset" + OpListToolboxes = "list_toolboxes" + OpGetToolbox = "get_toolbox" + OpCreateToolbox = "create_toolbox" + OpUpdateToolbox = "update_toolbox" + OpDeleteToolbox = "delete_toolbox" ) // Error codes commonly used for internal errors. diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/toolset_models.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/toolbox_models.go similarity index 71% rename from cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/toolset_models.go rename to cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/toolbox_models.go index 9c0c23975fb..795dcc1e9f9 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/toolset_models.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/toolbox_models.go @@ -5,14 +5,14 @@ package agent_api import "encoding/json" -// ToolsetAPIVersion is the API version for toolset operations. -const ToolsetAPIVersion = "v1" +// ToolboxAPIVersion is the API version for toolset operations. +const ToolboxAPIVersion = "v1" -// ToolsetFeatureHeader is the required preview feature flag header for toolset operations. -const ToolsetFeatureHeader = "Toolsets=V1Preview" +// ToolboxFeatureHeader is the required preview feature flag header for toolset operations. +const ToolboxFeatureHeader = "Toolsets=V1Preview" -// ToolsetObject represents a toolset returned by the Foundry Toolsets API. -type ToolsetObject struct { +// ToolboxObject represents a toolset returned by the Foundry Toolsets API. +type ToolboxObject struct { Object string `json:"object"` ID string `json:"id"` CreatedAt int64 `json:"created_at"` @@ -23,28 +23,28 @@ type ToolsetObject struct { Tools []json.RawMessage `json:"tools"` } -// ToolsetList represents the paginated list response from the Toolsets API. -type ToolsetList struct { - Data []ToolsetObject `json:"data"` +// ToolboxList represents the paginated list response from the Toolsets API. +type ToolboxList struct { + Data []ToolboxObject `json:"data"` } -// DeleteToolsetResponse represents the response from deleting a toolset. -type DeleteToolsetResponse struct { +// DeleteToolboxResponse represents the response from deleting a toolset. +type DeleteToolboxResponse struct { Object string `json:"object"` Name string `json:"name"` Deleted bool `json:"deleted"` } -// CreateToolsetRequest represents the request body for creating a toolset. -type CreateToolsetRequest struct { +// CreateToolboxRequest represents the request body for creating a toolset. +type CreateToolboxRequest struct { Name string `json:"name"` Description string `json:"description,omitempty"` Metadata map[string]string `json:"metadata,omitempty"` Tools []json.RawMessage `json:"tools"` } -// UpdateToolsetRequest represents the request body for updating a toolset. -type UpdateToolsetRequest struct { +// UpdateToolboxRequest represents the request body for updating a toolset. +type UpdateToolboxRequest struct { Description string `json:"description,omitempty"` Metadata map[string]string `json:"metadata,omitempty"` Tools []json.RawMessage `json:"tools"` diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/toolset_operations.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/toolbox_operations.go similarity index 72% rename from cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/toolset_operations.go rename to cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/toolbox_operations.go index fb9404c598a..263eb598f40 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/toolset_operations.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/toolbox_operations.go @@ -15,15 +15,15 @@ import ( "github.com/Azure/azure-sdk-for-go/sdk/azcore/streaming" ) -// ListToolsets returns all toolsets in the Foundry project. -func (c *AgentClient) ListToolsets(ctx context.Context, apiVersion string) (*ToolsetList, error) { +// ListToolboxes returns all toolboxes in the Foundry project. +func (c *AgentClient) ListToolboxes(ctx context.Context, apiVersion string) (*ToolboxList, error) { url := fmt.Sprintf("%s/toolsets?api-version=%s", c.endpoint, apiVersion) req, err := runtime.NewRequest(ctx, http.MethodGet, url) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } - req.Raw().Header.Set("Foundry-Features", ToolsetFeatureHeader) + req.Raw().Header.Set("Foundry-Features", ToolboxFeatureHeader) resp, err := c.pipeline.Do(req) if err != nil { @@ -40,7 +40,7 @@ func (c *AgentClient) ListToolsets(ctx context.Context, apiVersion string) (*Too return nil, fmt.Errorf("failed to read response body: %w", err) } - var list ToolsetList + var list ToolboxList if err := json.Unmarshal(body, &list); err != nil { return nil, fmt.Errorf("failed to parse response: %w", err) } @@ -48,15 +48,15 @@ func (c *AgentClient) ListToolsets(ctx context.Context, apiVersion string) (*Too return &list, nil } -// GetToolset retrieves a specific toolset by name. -func (c *AgentClient) GetToolset(ctx context.Context, name, apiVersion string) (*ToolsetObject, error) { +// GetToolbox retrieves a specific toolbox by name. +func (c *AgentClient) GetToolbox(ctx context.Context, name, apiVersion string) (*ToolboxObject, error) { url := fmt.Sprintf("%s/toolsets/%s?api-version=%s", c.endpoint, name, apiVersion) req, err := runtime.NewRequest(ctx, http.MethodGet, url) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } - req.Raw().Header.Set("Foundry-Features", ToolsetFeatureHeader) + req.Raw().Header.Set("Foundry-Features", ToolboxFeatureHeader) resp, err := c.pipeline.Do(req) if err != nil { @@ -73,18 +73,18 @@ func (c *AgentClient) GetToolset(ctx context.Context, name, apiVersion string) ( return nil, fmt.Errorf("failed to read response body: %w", err) } - var toolset ToolsetObject - if err := json.Unmarshal(body, &toolset); err != nil { + var toolbox ToolboxObject + if err := json.Unmarshal(body, &toolbox); err != nil { return nil, fmt.Errorf("failed to parse response: %w", err) } - return &toolset, nil + return &toolbox, nil } -// CreateToolset creates a new toolset. -func (c *AgentClient) CreateToolset( - ctx context.Context, request *CreateToolsetRequest, apiVersion string, -) (*ToolsetObject, error) { +// CreateToolbox creates a new toolbox. +func (c *AgentClient) CreateToolbox( + ctx context.Context, request *CreateToolboxRequest, apiVersion string, +) (*ToolboxObject, error) { url := fmt.Sprintf("%s/toolsets?api-version=%s", c.endpoint, apiVersion) payload, err := json.Marshal(request) @@ -96,7 +96,7 @@ func (c *AgentClient) CreateToolset( if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } - req.Raw().Header.Set("Foundry-Features", ToolsetFeatureHeader) + req.Raw().Header.Set("Foundry-Features", ToolboxFeatureHeader) if err := req.SetBody(streaming.NopCloser(bytes.NewReader(payload)), "application/json"); err != nil { return nil, fmt.Errorf("failed to set request body: %w", err) @@ -117,18 +117,18 @@ func (c *AgentClient) CreateToolset( return nil, fmt.Errorf("failed to read response body: %w", err) } - var toolset ToolsetObject - if err := json.Unmarshal(body, &toolset); err != nil { + var toolbox ToolboxObject + if err := json.Unmarshal(body, &toolbox); err != nil { return nil, fmt.Errorf("failed to parse response: %w", err) } - return &toolset, nil + return &toolbox, nil } -// UpdateToolset updates an existing toolset by name. -func (c *AgentClient) UpdateToolset( - ctx context.Context, name string, request *UpdateToolsetRequest, apiVersion string, -) (*ToolsetObject, error) { +// UpdateToolbox updates an existing toolbox by name. +func (c *AgentClient) UpdateToolbox( + ctx context.Context, name string, request *UpdateToolboxRequest, apiVersion string, +) (*ToolboxObject, error) { url := fmt.Sprintf("%s/toolsets/%s?api-version=%s", c.endpoint, name, apiVersion) payload, err := json.Marshal(request) @@ -140,7 +140,7 @@ func (c *AgentClient) UpdateToolset( if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } - req.Raw().Header.Set("Foundry-Features", ToolsetFeatureHeader) + req.Raw().Header.Set("Foundry-Features", ToolboxFeatureHeader) if err := req.SetBody(streaming.NopCloser(bytes.NewReader(payload)), "application/json"); err != nil { return nil, fmt.Errorf("failed to set request body: %w", err) @@ -161,23 +161,23 @@ func (c *AgentClient) UpdateToolset( return nil, fmt.Errorf("failed to read response body: %w", err) } - var toolset ToolsetObject - if err := json.Unmarshal(body, &toolset); err != nil { + var toolbox ToolboxObject + if err := json.Unmarshal(body, &toolbox); err != nil { return nil, fmt.Errorf("failed to parse response: %w", err) } - return &toolset, nil + return &toolbox, nil } -// DeleteToolset deletes a toolset by name. -func (c *AgentClient) DeleteToolset(ctx context.Context, name, apiVersion string) (*DeleteToolsetResponse, error) { +// DeleteToolbox deletes a toolbox by name. +func (c *AgentClient) DeleteToolbox(ctx context.Context, name, apiVersion string) (*DeleteToolboxResponse, error) { url := fmt.Sprintf("%s/toolsets/%s?api-version=%s", c.endpoint, name, apiVersion) req, err := runtime.NewRequest(ctx, http.MethodDelete, url) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } - req.Raw().Header.Set("Foundry-Features", ToolsetFeatureHeader) + req.Raw().Header.Set("Foundry-Features", ToolboxFeatureHeader) resp, err := c.pipeline.Do(req) if err != nil { @@ -194,10 +194,10 @@ func (c *AgentClient) DeleteToolset(ctx context.Context, name, apiVersion string return nil, fmt.Errorf("failed to read response body: %w", err) } - var deleteResponse DeleteToolsetResponse - if err := json.Unmarshal(body, &deleteResponse); err != nil { + var deleteResp DeleteToolboxResponse + if err := json.Unmarshal(body, &deleteResp); err != nil { return nil, fmt.Errorf("failed to parse response: %w", err) } - return &deleteResponse, nil + return &deleteResp, nil } diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/parse.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/parse.go index f859ff8a31a..318bed100d8 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/parse.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/parse.go @@ -170,6 +170,12 @@ func ExtractResourceDefinitions(manifestYamlContent []byte) ([]any, error) { return nil, fmt.Errorf("failed to unmarshal to ToolResource: %w", err) } resourceDefs = append(resourceDefs, toolDef) + case ResourceKindToolbox: + var toolboxDef ToolboxResource + if err := yaml.Unmarshal(resourceBytes, &toolboxDef); err != nil { + return nil, fmt.Errorf("failed to unmarshal to ToolboxResource: %w", err) + } + resourceDefs = append(resourceDefs, toolboxDef) default: return nil, fmt.Errorf("unrecognized resource kind: %s", resourceDef.Kind) } diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/yaml.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/yaml.go index bb9231c7651..24a02e60f6e 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/yaml.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/yaml.go @@ -31,8 +31,9 @@ func ValidAgentKinds() []AgentKind { type ResourceKind string const ( - ResourceKindModel ResourceKind = "model" - ResourceKindTool ResourceKind = "tool" + ResourceKindModel ResourceKind = "model" + ResourceKindTool ResourceKind = "tool" + ResourceKindToolbox ResourceKind = "toolbox" ) type ToolKind string @@ -305,6 +306,13 @@ type ToolResource struct { Options map[string]any `json:"options" yaml:"options"` } +// ToolboxResource Represents a Foundry project toolbox dependency +type ToolboxResource struct { + Resource `json:",inline" yaml:",inline"` + Id string `json:"id" yaml:"id"` + Options map[string]any `json:"options,omitempty" yaml:"options,omitempty"` +} + // Template Template model for defining prompt templates. // This model specifies the rendering engine used for slot filling prompts, // the parser used to process the rendered template into API-compatible format, diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/config.go b/cli/azd/extensions/azure.ai.agents/internal/project/config.go index 4694d649466..1f7258a4cf9 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/config.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/config.go @@ -6,6 +6,7 @@ package project import ( "encoding/json" "fmt" + "strings" "google.golang.org/protobuf/encoding/protojson" "google.golang.org/protobuf/types/known/structpb" @@ -48,6 +49,7 @@ type ServiceTargetAgentConfig struct { Container *ContainerSettings `json:"container,omitempty"` Deployments []Deployment `json:"deployments,omitempty"` Resources []Resource `json:"resources,omitempty"` + Toolboxes []Toolbox `json:"toolboxes,omitempty"` StartupCommand string `json:"startupCommand,omitempty"` } @@ -108,6 +110,34 @@ type Resource struct { ConnectionName string `json:"connectionName"` } +// Toolbox represents a Foundry project toolbox dependency for an agent. +// When Tools is populated, the toolbox will be created if it doesn't exist. +// When Tools is empty, the toolbox is expected to already exist. +type Toolbox struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Tools []json.RawMessage `json:"tools,omitempty"` +} + +// ToolboxNameToEnvVar converts a toolbox name to an environment variable prefix +// by upper-casing and replacing non-alphanumeric characters with underscores. +func ToolboxNameToEnvVar(name string) string { + var b strings.Builder + for _, r := range strings.ToUpper(name) { + if (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') { + b.WriteRune(r) + } else { + b.WriteByte('_') + } + } + return b.String() +} + +// ToolboxMcpEndpoint returns the MCP endpoint URL for a toolbox. +func ToolboxMcpEndpoint(projectEndpoint, toolboxName string) string { + return fmt.Sprintf("%s/toolsets/%s/mcp", projectEndpoint, toolboxName) +} + // UnmarshalStruct converts a structpb.Struct to a Go struct of type T func UnmarshalStruct[T any](s *structpb.Struct, out *T) error { structBytes, err := protojson.Marshal(s) diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go index 367e7fd003d..4310ba940e1 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go @@ -579,6 +579,7 @@ func (p *AgentServiceTargetProvider) deployPromptAgent( agentVersionResponse.Version, azdEnv["AZURE_AI_PROJECT_ID"], azdEnv["AZURE_AI_PROJECT_ENDPOINT"], + nil, ) return &azdext.ServiceDeployResult{ @@ -645,6 +646,13 @@ func (p *AgentServiceTargetProvider) deployHostedAgent( ) } + // Ensure toolbox dependencies exist before creating the agent + if len(foundryAgentConfig.Toolboxes) > 0 { + if err := p.ensureToolboxes(ctx, foundryAgentConfig.Toolboxes, azdEnv, progress); err != nil { + return nil, err + } + } + var cpu, memory string if foundryAgentConfig.Container != nil && foundryAgentConfig.Container.Resources != nil { cpu = foundryAgentConfig.Container.Resources.Cpu @@ -708,6 +716,7 @@ func (p *AgentServiceTargetProvider) deployHostedAgent( agentVersionResponse.Version, azdEnv["AZURE_AI_PROJECT_ID"], azdEnv["AZURE_AI_PROJECT_ENDPOINT"], + foundryAgentConfig.Toolboxes, ) return &azdext.ServiceDeployResult{ @@ -721,6 +730,7 @@ func (p *AgentServiceTargetProvider) deployArtifacts( agentVersion string, projectResourceID string, projectEndpoint string, + toolboxes []Toolbox, ) []*azdext.Artifact { artifacts := []*azdext.Artifact{} @@ -759,10 +769,24 @@ func (p *AgentServiceTargetProvider) deployArtifacts( }) } + // Add toolbox MCP endpoints + if projectEndpoint != "" { + for _, tb := range toolboxes { + mcpEndpoint := ToolboxMcpEndpoint(projectEndpoint, tb.Name) + artifacts = append(artifacts, &azdext.Artifact{ + Kind: azdext.ArtifactKind_ARTIFACT_KIND_ENDPOINT, + Location: mcpEndpoint, + LocationKind: azdext.LocationKind_LOCATION_KIND_REMOTE, + Metadata: map[string]string{ + "label": fmt.Sprintf("Toolbox MCP endpoint (%s)", tb.Name), + "clickable": "false", + }, + }) + } + } + return artifacts } - -// agentEndpoint constructs the agent endpoint URL from the provided parameters func (p *AgentServiceTargetProvider) agentEndpoint(projectEndpoint, agentName, agentVersion string) string { return fmt.Sprintf("%s/agents/%s/versions/%s", projectEndpoint, agentName, agentVersion) } @@ -827,6 +851,53 @@ func (p *AgentServiceTargetProvider) createAgent( return agentVersionResponse, nil } +// ensureToolboxes checks that each toolbox dependency exists, creating greenfield ones as needed. +func (p *AgentServiceTargetProvider) ensureToolboxes( + ctx context.Context, + toolboxes []Toolbox, + azdEnv map[string]string, + progress azdext.ProgressReporter, +) error { + agentClient := agent_api.NewAgentClient( + azdEnv["AZURE_AI_PROJECT_ENDPOINT"], + p.credential, + ) + + for _, tb := range toolboxes { + progress(fmt.Sprintf("Checking toolbox '%s'", tb.Name)) + _, err := agentClient.GetToolbox(ctx, tb.Name, agent_api.ToolboxAPIVersion) + if err == nil { + fmt.Fprintf(os.Stderr, "Toolbox '%s' already exists, skipping creation\n", tb.Name) + continue + } + + // If no tools defined, this is brownfield — the toolbox must already exist + if len(tb.Tools) == 0 { + return exterrors.Dependency( + exterrors.CodeToolboxNotFound, + fmt.Sprintf("toolbox '%s' not found and no tool definitions provided to create it", tb.Name), + fmt.Sprintf("create the toolbox first with 'azd ai agent toolbox create --name %s' "+ + "or add tool definitions in azure.yaml config.toolboxes", tb.Name), + ) + } + + // Greenfield — create the toolbox + progress(fmt.Sprintf("Creating toolbox '%s'", tb.Name)) + createReq := &agent_api.CreateToolboxRequest{ + Name: tb.Name, + Description: tb.Description, + Tools: tb.Tools, + } + _, err = agentClient.CreateToolbox(ctx, createReq, agent_api.ToolboxAPIVersion) + if err != nil { + return exterrors.ServiceFromAzure(err, exterrors.OpCreateToolbox) + } + fmt.Fprintf(os.Stderr, "Toolbox '%s' created successfully\n", tb.Name) + } + + return nil +} + // startAgentContainer starts the hosted agent container func (p *AgentServiceTargetProvider) startAgentContainer( ctx context.Context, diff --git a/cli/azd/extensions/azure.ai.agents/schemas/azure.ai.agent.json b/cli/azd/extensions/azure.ai.agents/schemas/azure.ai.agent.json index 22d77ce8341..6ca6f4ba081 100644 --- a/cli/azd/extensions/azure.ai.agents/schemas/azure.ai.agent.json +++ b/cli/azd/extensions/azure.ai.agents/schemas/azure.ai.agent.json @@ -24,6 +24,11 @@ "description": "List of external resources for agent execution.", "items": { "$ref": "#/definitions/Resource" } }, + "toolboxes": { + "type": "array", + "description": "List of Foundry project toolboxes the agent depends on.", + "items": { "$ref": "#/definitions/Toolbox" } + }, "startupCommand": { "type": "string", "description": "Command to start the agent server (e.g., 'python main.py'). Used by 'azd ai agent run' for local development." @@ -119,6 +124,21 @@ }, "required": ["resource", "connectionName"], "additionalProperties": false + }, + "Toolbox": { + "type": "object", + "description": "A Foundry project toolbox dependency.", + "properties": { + "name": { "type": "string", "description": "Toolbox name in the Foundry project." }, + "description": { "type": "string", "description": "Optional toolbox description." }, + "tools": { + "type": "array", + "description": "Tool definitions. If present, the toolbox will be created if it doesn't exist.", + "items": { "type": "object", "additionalProperties": true } + } + }, + "required": ["name"], + "additionalProperties": false } } } \ No newline at end of file From 490bf8f368a6f81839e860fe5a45f49b93d4d86f Mon Sep 17 00:00:00 2001 From: John Miller Date: Tue, 24 Mar 2026 15:44:21 -0400 Subject: [PATCH 5/5] feat: implement toolbox management commands and enhance agent manifest handling --- .../azure.ai.agents/internal/cmd/init.go | 53 +++++++++++ .../azure.ai.agents/internal/cmd/init_test.go | 89 +++++++++++++++++++ .../azure.ai.agents/internal/cmd/listen.go | 2 +- .../internal/cmd/toolbox_create.go | 8 +- .../internal/cmd/toolbox_delete.go | 2 +- .../internal/cmd/toolbox_show.go | 26 +++--- .../internal/cmd/toolbox_test.go | 20 +++++ .../pkg/agents/agent_api/toolbox_models.go | 14 +-- .../pkg/agents/agent_yaml/parse_test.go | 47 ++++++++++ .../internal/pkg/agents/agent_yaml/yaml.go | 1 + .../internal/project/config.go | 6 ++ 11 files changed, 242 insertions(+), 26 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go index a927be5f56d..639e02b8ea3 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -418,6 +418,12 @@ func (a *InitAction) Run(ctx context.Context) error { return fmt.Errorf("configuring model choice: %w", err) } + // Inject manifest resources (tool/toolbox) into the ContainerAgent tools section. + // The AgentSchema doesn't yet include tools on ContainerAgent, so resources + // defined at the manifest level need to be projected into the template so they + // are persisted in the local agent.yaml. + injectResourcesAsTools(agentManifest) + // Write the final agent.yaml to disk (after deployment names have been injected) if err := writeAgentDefinitionFile(targetDir, agentManifest); err != nil { return fmt.Errorf("writing agent definition: %w", err) @@ -1059,6 +1065,53 @@ func (a *InitAction) downloadAgentYaml( return agentManifest, targetDir, nil } +// injectResourcesAsTools projects manifest-level tool and toolbox resources into the +// ContainerAgent.Tools field so they are written to the local agent.yaml. This is a +// workaround until the AgentSchema adds native tools support on ContainerAgent. +func injectResourcesAsTools(manifest *agent_yaml.AgentManifest) { + if len(manifest.Resources) == 0 { + return + } + + containerAgent, ok := manifest.Template.(agent_yaml.ContainerAgent) + if !ok { + return + } + + var tools []any + if containerAgent.Tools != nil { + tools = *containerAgent.Tools + } + + for _, resource := range manifest.Resources { + switch res := resource.(type) { + case agent_yaml.ToolboxResource: + entry := map[string]any{ + "kind": string(agent_yaml.ResourceKindToolbox), + "id": res.Id, + } + if res.Options != nil { + entry["options"] = res.Options + } + tools = append(tools, entry) + case agent_yaml.ToolResource: + entry := map[string]any{ + "kind": string(agent_yaml.ResourceKindTool), + "id": res.Id, + } + if res.Options != nil { + entry["options"] = res.Options + } + tools = append(tools, entry) + } + } + + if len(tools) > 0 { + containerAgent.Tools = &tools + manifest.Template = containerAgent + } +} + // writeAgentDefinitionFile writes the agent definition to disk as agent.yaml in targetDir. // This should be called after all parameter/deployment injection is complete so the on-disk // file has fully resolved values (no `{{...}}` placeholders). diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_test.go index 6fb935235a3..c391f3c44c4 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_test.go @@ -9,6 +9,8 @@ import ( "path/filepath" "testing" + "azureaiagent/internal/pkg/agents/agent_yaml" + "github.com/azure/azure-dev/cli/azd/pkg/azdext" "google.golang.org/genproto/googleapis/rpc/errdetails" "google.golang.org/grpc/codes" @@ -394,3 +396,90 @@ func TestParseGitHubUrlNaive(t *testing.T) { }) } } + +func TestInjectResourcesAsTools(t *testing.T) { + t.Parallel() + + t.Run("toolbox resources become tools", func(t *testing.T) { + manifest := &agent_yaml.AgentManifest{ + Template: agent_yaml.ContainerAgent{ + AgentDefinition: agent_yaml.AgentDefinition{ + Kind: agent_yaml.AgentKindHosted, + Name: "test-agent", + }, + Protocols: []agent_yaml.ProtocolVersionRecord{ + {Protocol: "responses", Version: "v1"}, + }, + }, + Resources: []any{ + agent_yaml.ToolboxResource{ + Resource: agent_yaml.Resource{Kind: agent_yaml.ResourceKindToolbox}, + Id: "echo-github-toolset", + Options: map[string]any{ + "description": "GitHub tools", + }, + }, + }, + } + + injectResourcesAsTools(manifest) + + ca := manifest.Template.(agent_yaml.ContainerAgent) + if ca.Tools == nil { + t.Fatal("Expected tools to be injected, got nil") + } + if len(*ca.Tools) != 1 { + t.Fatalf("Expected 1 tool, got %d", len(*ca.Tools)) + } + + tool := (*ca.Tools)[0].(map[string]any) + if tool["kind"] != "toolbox" { + t.Errorf("Expected kind 'toolbox', got %v", tool["kind"]) + } + if tool["id"] != "echo-github-toolset" { + t.Errorf("Expected id 'echo-github-toolset', got %v", tool["id"]) + } + opts := tool["options"].(map[string]any) + if opts["description"] != "GitHub tools" { + t.Errorf("Expected description 'GitHub tools', got %v", opts["description"]) + } + }) + + t.Run("no resources is a no-op", func(t *testing.T) { + manifest := &agent_yaml.AgentManifest{ + Template: agent_yaml.ContainerAgent{ + AgentDefinition: agent_yaml.AgentDefinition{Kind: agent_yaml.AgentKindHosted, Name: "a"}, + }, + } + + injectResourcesAsTools(manifest) + + ca := manifest.Template.(agent_yaml.ContainerAgent) + if ca.Tools != nil { + t.Errorf("Expected nil tools, got %v", ca.Tools) + } + }) + + t.Run("preserves existing template tools", func(t *testing.T) { + existing := []any{map[string]any{"kind": "mcp", "id": "existing-tool"}} + manifest := &agent_yaml.AgentManifest{ + Template: agent_yaml.ContainerAgent{ + AgentDefinition: agent_yaml.AgentDefinition{Kind: agent_yaml.AgentKindHosted, Name: "a"}, + Tools: &existing, + }, + Resources: []any{ + agent_yaml.ToolboxResource{ + Resource: agent_yaml.Resource{Kind: agent_yaml.ResourceKindToolbox}, + Id: "new-toolbox", + }, + }, + } + + injectResourcesAsTools(manifest) + + ca := manifest.Template.(agent_yaml.ContainerAgent) + if len(*ca.Tools) != 2 { + t.Fatalf("Expected 2 tools (1 existing + 1 injected), got %d", len(*ca.Tools)) + } + }) +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go index acbd5f86500..b6d30e767cc 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go @@ -242,7 +242,7 @@ func toolboxEnvUpdate(ctx context.Context, toolboxes []project.Toolbox, azdClien for _, tb := range toolboxes { mcpEndpoint := project.ToolboxMcpEndpoint(projectEndpoint, tb.Name) - envVar := project.ToolboxNameToEnvVar(tb.Name) + "_MCP_ENDPOINT" + envVar := project.ToolboxEnvVar(tb.Name) if err := setEnvVar(ctx, azdClient, envName, envVar, mcpEndpoint); err != nil { return err } diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/toolbox_create.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/toolbox_create.go index 4c7c2c4f49c..4e4a478d68a 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/toolbox_create.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/toolbox_create.go @@ -23,7 +23,7 @@ func newToolboxCreateCommand() *cobra.Command { cmd := &cobra.Command{ Use: "create ", Short: "Create a toolbox in the Foundry project.", - Long: `Create a new toolset from a JSON payload file. + Long: `Create a new toolbox from a JSON payload file. The payload file must contain a JSON object with at least "name" and "tools" fields. If a toolbox with the same name already exists, you will be prompted to confirm @@ -102,7 +102,7 @@ before overwriting (use --no-prompt to auto-confirm).`, // Check if toolbox already exists existing, err := client.GetToolbox(ctx, createReq.Name, agent_api.ToolboxAPIVersion) if err == nil && existing != nil { - // Toolset exists — prompt for overwrite confirmation + // Toolbox exists — prompt for overwrite confirmation if !rootFlags.NoPrompt { azdClient, azdErr := azdext.NewAzdClient() if azdErr != nil { @@ -124,7 +124,7 @@ before overwriting (use --no-prompt to auto-confirm).`, } return fmt.Errorf("failed to prompt for confirmation: %w", promptErr) } - if !*resp.Value { + if resp == nil || resp.Value == nil || !*resp.Value { fmt.Println("toolbox creation cancelled.") return nil } @@ -173,7 +173,7 @@ before overwriting (use --no-prompt to auto-confirm).`, } func printMcpEnvTip(toolboxName, mcpEndpoint string) { - envVar := project.ToolboxNameToEnvVar(toolboxName) + "_MCP_ENDPOINT" + envVar := project.ToolboxEnvVar(toolboxName) fmt.Println() fmt.Println(output.WithHintFormat( "Hint: Store the endpoint in your azd environment so your agent code can reference it:")) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/toolbox_delete.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/toolbox_delete.go index 3c05cc2f1be..10e08370cb3 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/toolbox_delete.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/toolbox_delete.go @@ -66,7 +66,7 @@ You will be prompted to confirm before deleting (use --no-prompt to auto-confirm } return fmt.Errorf("failed to prompt for confirmation: %w", promptErr) } - if !*resp.Value { + if resp == nil || resp.Value == nil || !*resp.Value { fmt.Println("toolbox deletion cancelled.") return nil } diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/toolbox_show.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/toolbox_show.go index b2f8d97dae1..a108fe9ba9f 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/toolbox_show.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/toolbox_show.go @@ -85,9 +85,9 @@ type toolboxShowOutput struct { MCPEndpoint string `json:"mcp_endpoint"` } -func printToolboxShowJSON(toolset *agent_api.ToolboxObject, mcpEndpoint string) error { +func printToolboxShowJSON(toolbox *agent_api.ToolboxObject, mcpEndpoint string) error { output := toolboxShowOutput{ - ToolboxObject: *toolset, + ToolboxObject: *toolbox, MCPEndpoint: mcpEndpoint, } @@ -99,26 +99,26 @@ func printToolboxShowJSON(toolset *agent_api.ToolboxObject, mcpEndpoint string) return nil } -func printToolboxShowTable(toolset *agent_api.ToolboxObject, mcpEndpoint string) error { +func printToolboxShowTable(toolbox *agent_api.ToolboxObject, mcpEndpoint string) error { w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) fmt.Fprintln(w, "FIELD\tVALUE") fmt.Fprintln(w, "-----\t-----") - fmt.Fprintf(w, "Name\t%s\n", toolset.Name) - fmt.Fprintf(w, "ID\t%s\n", toolset.ID) - if toolset.Description != "" { - fmt.Fprintf(w, "Description\t%s\n", toolset.Description) + fmt.Fprintf(w, "Name\t%s\n", toolbox.Name) + fmt.Fprintf(w, "ID\t%s\n", toolbox.ID) + if toolbox.Description != "" { + fmt.Fprintf(w, "Description\t%s\n", toolbox.Description) } - if toolset.CreatedAt > 0 { - fmt.Fprintf(w, "Created\t%s\n", time.Unix(toolset.CreatedAt, 0).Format(time.RFC3339)) + if toolbox.CreatedAt > 0 { + fmt.Fprintf(w, "Created\t%s\n", time.Unix(toolbox.CreatedAt, 0).Format(time.RFC3339)) } - if toolset.UpdatedAt > 0 { - fmt.Fprintf(w, "Updated\t%s\n", time.Unix(toolset.UpdatedAt, 0).Format(time.RFC3339)) + if toolbox.UpdatedAt > 0 { + fmt.Fprintf(w, "Updated\t%s\n", time.Unix(toolbox.UpdatedAt, 0).Format(time.RFC3339)) } - fmt.Fprintf(w, "Tools\t%d\n", len(toolset.Tools)) - for i, raw := range toolset.Tools { + fmt.Fprintf(w, "Tools\t%d\n", len(toolbox.Tools)) + for i, raw := range toolbox.Tools { toolType, toolName := agent_api.ToolSummary(raw) label := toolType if toolName != "" { diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/toolbox_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/toolbox_test.go index 0962d623c35..180ddd0fe90 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/toolbox_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/toolbox_test.go @@ -102,6 +102,26 @@ func TestToolboxNameToEnvVar(t *testing.T) { } } +func TestToolboxEnvVar(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + {"simple", "my-toolbox", "FOUNDRY_TOOLBOX__MY_TOOLBOX__ENDPOINT"}, + {"echo github toolset", "echo-github-toolset", "FOUNDRY_TOOLBOX__ECHO_GITHUB_TOOLSET__ENDPOINT"}, + {"already upper", "MY_TOOLBOX", "FOUNDRY_TOOLBOX__MY_TOOLBOX__ENDPOINT"}, + {"empty name", "", "FOUNDRY_TOOLBOX____ENDPOINT"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := project.ToolboxEnvVar(tt.in) + assert.Equal(t, tt.want, got) + }) + } +} + func TestPrintToolboxListTable_Empty(t *testing.T) { list := &agent_api.ToolboxList{} err := printToolboxListTable(t.Context(), list) diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/toolbox_models.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/toolbox_models.go index 795dcc1e9f9..25c766ef443 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/toolbox_models.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/toolbox_models.go @@ -5,13 +5,13 @@ package agent_api import "encoding/json" -// ToolboxAPIVersion is the API version for toolset operations. +// ToolboxAPIVersion is the API version for toolbox operations. const ToolboxAPIVersion = "v1" -// ToolboxFeatureHeader is the required preview feature flag header for toolset operations. +// ToolboxFeatureHeader is the required preview feature flag header for toolbox operations. const ToolboxFeatureHeader = "Toolsets=V1Preview" -// ToolboxObject represents a toolset returned by the Foundry Toolsets API. +// ToolboxObject represents a toolbox returned by the Foundry Toolboxes API. type ToolboxObject struct { Object string `json:"object"` ID string `json:"id"` @@ -23,19 +23,19 @@ type ToolboxObject struct { Tools []json.RawMessage `json:"tools"` } -// ToolboxList represents the paginated list response from the Toolsets API. +// ToolboxList represents the paginated list response from the Toolboxes API. type ToolboxList struct { Data []ToolboxObject `json:"data"` } -// DeleteToolboxResponse represents the response from deleting a toolset. +// DeleteToolboxResponse represents the response from deleting a toolbox. type DeleteToolboxResponse struct { Object string `json:"object"` Name string `json:"name"` Deleted bool `json:"deleted"` } -// CreateToolboxRequest represents the request body for creating a toolset. +// CreateToolboxRequest represents the request body for creating a toolbox. type CreateToolboxRequest struct { Name string `json:"name"` Description string `json:"description,omitempty"` @@ -43,7 +43,7 @@ type CreateToolboxRequest struct { Tools []json.RawMessage `json:"tools"` } -// UpdateToolboxRequest represents the request body for updating a toolset. +// UpdateToolboxRequest represents the request body for updating a toolbox. type UpdateToolboxRequest struct { Description string `json:"description,omitempty"` Metadata map[string]string `json:"metadata,omitempty"` diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/parse_test.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/parse_test.go index 3679d528713..016c3b7cc9f 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/parse_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/parse_test.go @@ -40,6 +40,53 @@ template: } } +// TestExtractAgentDefinition_WithTools tests that tools in the template are preserved during parsing +func TestExtractAgentDefinition_WithTools(t *testing.T) { + yamlContent := []byte(` +name: test-manifest +template: + kind: hosted + name: test-agent + protocols: + - protocol: responses + version: v1 + tools: + - type: toolbox + toolbox_name: echo-github-toolset +`) + + agent, err := ExtractAgentDefinition(yamlContent) + if err != nil { + t.Fatalf("ExtractAgentDefinition failed: %v", err) + } + + containerAgent, ok := agent.(ContainerAgent) + if !ok { + t.Fatalf("Expected ContainerAgent, got %T", agent) + } + + if containerAgent.Tools == nil { + t.Fatal("Expected tools to be present, got nil") + } + + if len(*containerAgent.Tools) != 1 { + t.Fatalf("Expected 1 tool, got %d", len(*containerAgent.Tools)) + } + + tool, ok := (*containerAgent.Tools)[0].(map[string]any) + if !ok { + t.Fatalf("Expected tool to be map[string]any, got %T", (*containerAgent.Tools)[0]) + } + + if tool["type"] != "toolbox" { + t.Errorf("Expected tool type 'toolbox', got '%v'", tool["type"]) + } + + if tool["toolbox_name"] != "echo-github-toolset" { + t.Errorf("Expected toolbox_name 'echo-github-toolset', got '%v'", tool["toolbox_name"]) + } +} + // TestExtractAgentDefinition_EmptyTemplateField tests that an empty or null template field returns an error func TestExtractAgentDefinition_EmptyTemplateField(t *testing.T) { testCases := []struct { diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/yaml.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/yaml.go index 24a02e60f6e..75ea3eb5466 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/yaml.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/yaml.go @@ -105,6 +105,7 @@ type Workflow struct { type ContainerAgent struct { AgentDefinition `json:",inline" yaml:",inline"` Protocols []ProtocolVersionRecord `json:"protocols" yaml:"protocols"` + Tools *[]any `json:"tools,omitempty" yaml:"tools,omitempty"` EnvironmentVariables *[]EnvironmentVariable `json:"environmentVariables,omitempty" yaml:"environment_variables,omitempty"` } diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/config.go b/cli/azd/extensions/azure.ai.agents/internal/project/config.go index 1f7258a4cf9..2b58ff10dbe 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/config.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/config.go @@ -133,6 +133,12 @@ func ToolboxNameToEnvVar(name string) string { return b.String() } +// ToolboxEnvVar returns the full environment variable name for a toolbox's MCP endpoint. +// The format is FOUNDRY_TOOLBOX____ENDPOINT. +func ToolboxEnvVar(name string) string { + return "FOUNDRY_TOOLBOX__" + ToolboxNameToEnvVar(name) + "__ENDPOINT" +} + // ToolboxMcpEndpoint returns the MCP endpoint URL for a toolbox. func ToolboxMcpEndpoint(projectEndpoint, toolboxName string) string { return fmt.Sprintf("%s/toolsets/%s/mcp", projectEndpoint, toolboxName)