Skip to content
Merged
10 changes: 6 additions & 4 deletions cli/azd/extensions/azure.ai.toolboxes/internal/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,10 @@ func NewRootCommand() *cobra.Command {
Long: `Manage Foundry toolboxes.

A toolbox is a versioned, named collection of connection-backed tools that
agents reference at run time. Each version is immutable and carries the full
tool list; mutations publish a new version and (after the first one) require
an explicit update to retarget the default.`,
agents reference at run time. Each version is immutable: mutations (connection
add/remove, skill add/remove) create a new version but never change which
version is the default. Use 'azd ai toolbox publish <toolbox> <version>'
to promote a version.`,
})

rootCmd.SilenceUsage = true
Expand All @@ -42,12 +43,13 @@ an explicit update to retarget the default.`,
registerToolboxOutputFlag(rootCmd)

rootCmd.AddCommand(newToolboxCreateCommand(extCtx))
rootCmd.AddCommand(newToolboxUpdateCommand(extCtx))
rootCmd.AddCommand(newToolboxPublishCommand(extCtx))
rootCmd.AddCommand(newToolboxDeleteCommand(extCtx))
rootCmd.AddCommand(newToolboxShowCommand(extCtx))
rootCmd.AddCommand(newToolboxListCommand(extCtx))
rootCmd.AddCommand(newToolboxVersionCommand(extCtx))
rootCmd.AddCommand(newToolboxConnectionCommand(extCtx))
rootCmd.AddCommand(newToolboxSkillCommand(extCtx))

rootCmd.AddCommand(newVersionCommand(&extCtx.OutputFormat))
rootCmd.AddCommand(newMetadataCommand(rootCmd))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ func TestRunToolboxDeleteWith_Branches(t *testing.T) {
toolboxDeleteFlags{version: "2", force: true}, toolboxFlags{output: "table"},
)
le := requireLocalError(t, err, exterrors.CodeDefaultVersionDelete)
assert.Contains(t, le.Suggestion, "default-version")
assert.Contains(t, le.Suggestion, "azd ai toolbox publish")
assert.Empty(t, client.deleteVersionCalls, "service must not be called")
})

Expand Down Expand Up @@ -230,7 +230,7 @@ func TestRunConnectionAddWith_AppendsAndPromotesDefault(t *testing.T) {
require.NotNil(t, req.Policies, "policies must be carried forward")
require.NotNil(t, req.Policies.RaiConfig)
assert.Equal(t, "Microsoft.Default", req.Policies.RaiConfig.RaiPolicyName)
require.Len(t, client.setDefaultCalls, 1, "default version must be retargeted")
assert.Empty(t, client.setDefaultCalls, "mutation verbs no longer auto-promote default")
}

func TestRunConnectionAddWith_ConnectionNotFound(t *testing.T) {
Expand Down Expand Up @@ -290,7 +290,7 @@ func TestRunConnectionAddWith_FromFileAddsMultipleToolsSingleVersion(t *testing.
require.NoError(t, err)
require.Len(t, client.createVersionCalls, 1, "single version increment for batch input")
assert.Len(t, client.createVersionCalls[0].req.Tools, 3, "existing + 2 additions")
require.Len(t, client.setDefaultCalls, 1)
assert.Empty(t, client.setDefaultCalls)
}

// Public entry-point validation: empty connection without --from-file.
Expand Down Expand Up @@ -319,7 +319,9 @@ func TestRunConnectionRemoveWith_LastToolBlocks(t *testing.T) {

err := runConnectionRemoveWith(
t.Context(), client, resolver, "https://e/",
"tb", "a", connectionRemoveFlags{force: true}, toolboxFlags{output: "table"},
"tb", []string{"a"},
connectionRemoveFlags{force: true},
toolboxFlags{output: "table"},
)
requireLocalError(t, err, exterrors.CodeLastToolRemoval)
assert.Empty(t, client.createVersionCalls)
Expand All @@ -344,15 +346,17 @@ func TestRunConnectionRemoveWith_FilteredAndPromoted(t *testing.T) {

err := runConnectionRemoveWith(
t.Context(), client, resolver, "https://e/",
"tb", "a", connectionRemoveFlags{force: true}, toolboxFlags{output: "json"},
"tb", []string{"a"},
connectionRemoveFlags{force: true},
toolboxFlags{output: "json"},
)
require.NoError(t, err)
require.Len(t, client.createVersionCalls, 1)
req := client.createVersionCalls[0].req
assert.Len(t, req.Tools, 1)
require.NotNil(t, req.Policies, "policies must be carried forward on remove")
assert.Equal(t, "Microsoft.Default", req.Policies.RaiConfig.RaiPolicyName)
require.Len(t, client.setDefaultCalls, 1)
assert.Empty(t, client.setDefaultCalls)
}

func TestRunConnectionRemoveWith_ConnectionNotInToolbox(t *testing.T) {
Expand All @@ -370,7 +374,9 @@ func TestRunConnectionRemoveWith_ConnectionNotInToolbox(t *testing.T) {

err := runConnectionRemoveWith(
t.Context(), client, resolver, "https://e/",
"tb", "a", connectionRemoveFlags{force: true}, toolboxFlags{output: "table"},
"tb", []string{"a"},
connectionRemoveFlags{force: true},
toolboxFlags{output: "table"},
)
requireLocalError(t, err, exterrors.CodeConnectionNotInToolbox)
}
Expand Down Expand Up @@ -400,10 +406,9 @@ func TestRunConnectionListWith_EmitsAllShapes(t *testing.T) {
require.NoError(t, err)
}

func TestRunToolboxUpdate_MissingDefaultVersion(t *testing.T) {
err := runToolboxUpdate(
t.Context(), "tb",
toolboxUpdateFlags{},
func TestRunToolboxPublish_WhitespaceVersion(t *testing.T) {
err := runToolboxPublish(
t.Context(), "tb", " ",
toolboxFlags{output: "table"},
)
requireLocalError(t, err, exterrors.CodeMissingUpdateField)
Expand Down Expand Up @@ -435,6 +440,77 @@ connections:
assert.Len(t, client.createVersionCalls[0].req.Tools, 1)
}

func TestRunToolboxCreateWith_SkillsFromFile(t *testing.T) {
client := newMockToolboxClient("https://e/")
resolver := newStubConnectionResolver()
resolver.byName["mcp"] = &projectConnection{
ID: "/c/mcp", Category: connections.ConnectionTypeRemoteTool, Name: "mcp",
Target: "https://mcp.example.com",
}

inputPath := t.TempDir() + "/create.yaml"
require.NoError(t, os.WriteFile(inputPath, []byte(`
description: tb with skills
connections:
- name: mcp
skills:
- name: pinned
version: "3"
- name: unpinned
`), 0o600))

err := runToolboxCreateWith(
t.Context(), client, resolver, "https://e/", "tb",
toolboxCreateFlags{fromFile: inputPath},
toolboxFlags{output: "json"},
)
require.NoError(t, err)
require.Len(t, client.createVersionCalls, 1)

skills := client.createVersionCalls[0].req.Skills
require.Len(t, skills, 2)

byName := map[string]map[string]any{}
for _, s := range skills {
n, _ := s["name"].(string)
byName[n] = s
}
require.Contains(t, byName, "pinned")
require.Contains(t, byName, "unpinned")
assert.Equal(t, "skill_reference", byName["pinned"]["type"])
assert.Equal(t, "3", byName["pinned"]["version"])
_, hasVersion := byName["unpinned"]["version"]
assert.False(t, hasVersion, "skill without version must omit the version key")
}

func TestRunToolboxCreateWith_DuplicateSkillRejected(t *testing.T) {
client := newMockToolboxClient("https://e/")
resolver := newStubConnectionResolver()
resolver.byName["mcp"] = &projectConnection{
ID: "/c/mcp", Category: connections.ConnectionTypeRemoteTool, Name: "mcp",
Target: "https://mcp.example.com",
}

inputPath := t.TempDir() + "/create.yaml"
require.NoError(t, os.WriteFile(inputPath, []byte(`
description: tb
connections:
- name: mcp
skills:
- name: dup
- name: dup
version: "2"
`), 0o600))

err := runToolboxCreateWith(
t.Context(), client, resolver, "https://e/", "tb",
toolboxCreateFlags{fromFile: inputPath},
toolboxFlags{output: "json"},
)
requireLocalError(t, err, exterrors.CodeDuplicateSkill)
assert.Empty(t, client.createVersionCalls, "no version should be created when local validation fails")
}

func TestRunToolboxCreateWith_AlreadyExists(t *testing.T) {
client := newMockToolboxClient("https://e/")
client.getResults["tb"] = toolboxGetResult{obj: &azure.ToolboxObject{Name: "tb", DefaultVersion: "1"}}
Expand Down Expand Up @@ -733,11 +809,107 @@ func TestRunToolboxVersionListWith_ListVersionsServiceError(t *testing.T) {
}

func TestRunConnectionRemove_NoPromptWithoutForce(t *testing.T) {
err := runConnectionRemove(
t.Context(), "tb", "conn",
err := runConnectionRemove(t.Context(), "tb", []string{"conn"},
connectionRemoveFlags{force: false},
toolboxFlags{output: "table", noPrompt: true},
newStubConnectionResolver(),
)
requireLocalError(t, err, exterrors.CodeMissingForceFlag)
}

// Carry-forward: skills attached to the current default version must survive
// across new versions created by `connection add`.
func TestRunConnectionAddWith_CarriesForwardSkills(t *testing.T) {
skills := []map[string]any{
{"type": "skill_reference", "name": "alpha", "version": "1"},
{"type": "skill_reference", "name": "beta"},
}
client := newMockToolboxClient("https://e/")
client.getResults["tb"] = toolboxGetResult{obj: &azure.ToolboxObject{
Name: "tb", DefaultVersion: "1",
}}
client.versionResults["tb/1"] = toolboxVersionResult{obj: &azure.ToolboxVersionObject{
Name: "tb", Version: "1", Description: "first",
Tools: []map[string]any{
{"type": "mcp", "name": "a", "project_connection_id": "/c/a"},
},
Skills: skills,
}}
resolver := newStubConnectionResolver()
resolver.byName["b"] = &projectConnection{
ID: "/c/b", Category: connections.ConnectionTypeRemoteTool, Name: "b", Target: "https://mcp-b",
}

err := runConnectionAddWith(
t.Context(), client, resolver, "https://e/",
"tb", "b", connectionAddFlags{}, toolboxFlags{output: "json"},
)
require.NoError(t, err)
require.Len(t, client.createVersionCalls, 1)
assert.Equal(t, skills, client.createVersionCalls[0].req.Skills,
"skills must be carried forward verbatim into the new version")
}

// Carry-forward: skills attached to the current default version must survive
// across new versions created by `connection remove`.
func TestRunConnectionRemoveWith_CarriesForwardSkills(t *testing.T) {
skills := []map[string]any{
{"type": "skill_reference", "name": "alpha"},
}
client := newMockToolboxClient("https://e/")
client.getResults["tb"] = toolboxGetResult{obj: &azure.ToolboxObject{
Name: "tb", DefaultVersion: "1",
}}
client.versionResults["tb/1"] = toolboxVersionResult{obj: &azure.ToolboxVersionObject{
Name: "tb", Version: "1",
Tools: []map[string]any{
{"type": "mcp", "name": "a", "project_connection_id": "/c/a"},
{"type": "mcp", "name": "b", "project_connection_id": "/c/b"},
},
Skills: skills,
}}
resolver := newStubConnectionResolver()
resolver.byName["a"] = &projectConnection{
ID: "/c/a", Category: connections.ConnectionTypeRemoteTool, Name: "a",
}

err := runConnectionRemoveWith(
t.Context(), client, resolver, "https://e/",
"tb", []string{"a"},
connectionRemoveFlags{force: true},
toolboxFlags{output: "json"},
)
require.NoError(t, err)
require.Len(t, client.createVersionCalls, 1)
assert.Equal(t, skills, client.createVersionCalls[0].req.Skills,
"skills must be carried forward verbatim into the new version")
}

// Batch removal via variadic positionals.
func TestRunConnectionRemoveWith_VariadicPositionals(t *testing.T) {
client := newMockToolboxClient("https://e/")
client.getResults["tb"] = toolboxGetResult{obj: &azure.ToolboxObject{
Name: "tb", DefaultVersion: "1",
}}
client.versionResults["tb/1"] = toolboxVersionResult{obj: &azure.ToolboxVersionObject{
Name: "tb", Version: "1",
Tools: []map[string]any{
{"type": "mcp", "name": "a", "project_connection_id": "/c/a"},
{"type": "mcp", "name": "b", "project_connection_id": "/c/b"},
{"type": "mcp", "name": "c", "project_connection_id": "/c/c"},
},
}}
resolver := newStubConnectionResolver()
resolver.byName["a"] = &projectConnection{ID: "/c/a", Name: "a", Category: connections.ConnectionTypeRemoteTool}
resolver.byName["b"] = &projectConnection{ID: "/c/b", Name: "b", Category: connections.ConnectionTypeRemoteTool}
Comment thread
hund030 marked this conversation as resolved.

err := runConnectionRemoveWith(
t.Context(), client, resolver, "https://e/",
"tb", []string{"a", "b"},
connectionRemoveFlags{force: true}, toolboxFlags{output: "json"},
)
require.NoError(t, err)
require.Len(t, client.createVersionCalls, 1, "one new version created for the whole batch")
require.Len(t, client.createVersionCalls[0].req.Tools, 1)
assert.Equal(t, "/c/c", client.createVersionCalls[0].req.Tools[0]["project_connection_id"])
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,9 @@ func newToolboxConnectionCommand(extCtx *azdext.ExtensionContext) *cobra.Command

Tools are project connections. Supported categories: RemoteTool (MCP),
CognitiveSearch (Azure AI Search), RemoteA2A, and GroundingWithCustomSearch.
Each mutation publishes a new immutable version and retargets the toolbox
default.`,
Each mutation creates a new immutable version; the toolbox's default version
is unchanged. Use 'azd ai toolbox publish <toolbox> <version>'
to promote a version.`,
}
cmd.AddCommand(newToolboxConnectionAddCommand(extCtx))
cmd.AddCommand(newToolboxConnectionRemoveCommand(extCtx))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ func newToolboxConnectionAddCommand(extCtx *azdext.ExtensionContext) *cobra.Comm
cmd := &cobra.Command{
Use: "add <toolbox> [connection]",
Short: "Attach one or more connections to a toolbox.",
Long: `Attach one or more tools to a toolbox and publish a new default version.
Long: `Attach one or more tools to a toolbox and create a new version.

This command has two modes:

Expand All @@ -42,16 +42,18 @@ Single-connection mode:
Pass the project connection's short name as the positional. --index is
required when the connection's category is CognitiveSearch (Azure AI Search).
--instance-name is required when the category is GroundingWithCustomSearch.
Only one tool is appended; the new version becomes the default.

File mode:

azd ai toolbox connection add <toolbox> --from-file <path>

Provide a JSON or YAML file with multiple connections. All inputs from a
single invocation publish exactly one new toolbox version, so adding three
single invocation create exactly one new toolbox version, so adding three
connections this way produces v(N+1), not v(N+3).

The new version is created but the toolbox's default version is unchanged;
run 'azd ai toolbox publish <toolbox> <version>' to promote it.

` + fileShapeBlurb(false) + `

At least one connection must be provided.
Expand Down Expand Up @@ -237,27 +239,14 @@ func runConnectionAddWith(
Description: current.Description,
Metadata: current.Metadata,
Tools: newTools,
Skills: current.Skills,
Policies: current.Policies,
}
created, err := client.CreateToolboxVersion(ctx, toolboxName, req)
if err != nil {
return exterrors.ServiceFromAzure(err, exterrors.OpCreateToolboxVersion)
}

if _, err := client.SetDefaultVersion(ctx, toolboxName, created.Version); err != nil {
return exterrors.Dependency(
exterrors.CodeSetDefaultVersionFailed,
fmt.Sprintf(
"toolbox %q version %q was created but could not be promoted to default: %s",
toolboxName, created.Version, err,
),
fmt.Sprintf(
"run `azd ai toolbox update %q --default-version %q` to retarget the default",
toolboxName, created.Version,
),
)
}

return emitConnectionAddResult(toolboxName, created.Version, addedConnectionNames, parent.output, endpoint)
}

Expand Down Expand Up @@ -311,10 +300,12 @@ func emitConnectionAddResult(
return emitJSON(payload)
}

fmt.Printf("Attached connection(s) to toolbox %s (now at version %s).\n", toolboxName, newVersion)
fmt.Printf("Created toolbox %s version %s.\n", toolboxName, newVersion)
if len(connectionNames) > 0 {
fmt.Printf("Connections: %s\n", strings.Join(connectionNames, ", "))
}
fmt.Printf("Endpoint: %s\n", mcpURL)
fmt.Printf("The default version is unchanged; "+
"run `azd ai toolbox publish %q %q` to promote.\n", toolboxName, newVersion)
return nil
}
Loading
Loading