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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions cli/azd/extensions/azure.ai.toolboxes/README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,25 @@
# Foundry Toolboxes

Manage Microsoft Foundry Toolboxes from your terminal. (Preview)

## Reuse an existing toolbox in `azure.yaml`

A `host: azure.ai.toolbox` service normally creates a new toolbox version from
its `tools` on each `azd deploy`. To reuse a toolbox that already exists (for
example one shared across projects, or created with `azd ai toolbox create`),
set `endpoint` to its MCP endpoint instead. azd then publishes that endpoint for
Comment thread
huimiu marked this conversation as resolved.
agents without creating a new version. This mirrors the `azure.ai.project`
`endpoint` field: omit it to create, set it to reuse.

```yaml
services:
research-tools:
host: azure.ai.toolbox
endpoint: ${RESEARCH_TOOLBOX_ENDPOINT}
```

Get the endpoint value from `azd ai toolbox show <name>` (the `Endpoint:` line).
The value may contain `${VAR}` references, which resolve against the azd
environment. Because a toolbox version is immutable, `endpoint` cannot be
combined with `tools` or `description`.

104 changes: 104 additions & 0 deletions cli/azd/extensions/azure.ai.toolboxes/internal/cmd/service_target.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import (
"context"
"encoding/json"
"fmt"
"strings"

"azure.ai.toolboxes/internal/exterrors"
"azure.ai.toolboxes/internal/foundry/projectctx"
"azure.ai.toolboxes/internal/pkg/azure"

Expand All @@ -29,6 +31,12 @@ var _ azdext.ServiceTargetProvider = (*toolboxServiceTarget)(nil)
// not a body field. Each tool is a verbatim data-plane tool object; a tool that names a
// `connection` is resolved to its project_connection_id at deploy time.
type toolboxServiceConfig struct {
// Endpoint points at an existing Foundry toolbox version's MCP
// endpoint (bring-your-own). Its presence is the reuse signal:
// azd publishes it for agents instead of creating a new version,
// mirroring the azure.ai.project brownfield endpoint. Mutually
// exclusive with Tools and Description (a version is immutable).
Endpoint string `json:"endpoint,omitempty"`
Description string `json:"description,omitempty"`
Tools []map[string]any `json:"tools,omitempty"`
}
Expand Down Expand Up @@ -109,6 +117,8 @@ func (p *toolboxServiceTarget) Publish(
// against the azd environment; Foundry ${{...}} expressions pass through untouched.
// Removing the service from azure.yaml stops azd managing the toolbox but does not delete
// it (use `azd ai toolbox delete`).
// When the entry sets `endpoint` instead, azd reuses that existing
// toolbox and skips version creation (see deployReuse).
func (p *toolboxServiceTarget) Deploy(
ctx context.Context,
serviceConfig *azdext.ServiceConfig,
Expand All @@ -122,6 +132,12 @@ func (p *toolboxServiceTarget) Deploy(
}
name := serviceConfig.GetName()

// Reuse (bring-your-own): endpoint set means azd resolves ${VAR}
// and publishes it for agents instead of creating a version.
if strings.TrimSpace(cfg.Endpoint) != "" {
return p.deployReuse(ctx, name, cfg, progress)
}

resolved, err := projectctx.Resolve(ctx, projectctx.ResolveOpts{})
if err != nil {
return nil, err
Expand Down Expand Up @@ -164,6 +180,94 @@ func (p *toolboxServiceTarget) Deploy(
return &azdext.ServiceDeployResult{}, nil
}

// deployReuse publishes an existing toolbox's MCP endpoint to
// the azd environment instead of creating a new version. It
// mirrors the azure.ai.project brownfield endpoint: the
// toolbox is managed elsewhere, so azd only wires the
// consumption endpoint for agents.
func (p *toolboxServiceTarget) deployReuse(
ctx context.Context,
name string,
cfg *toolboxServiceConfig,
progress azdext.ProgressReporter,
) (*azdext.ServiceDeployResult, error) {
env, err := p.currentEnvValues(ctx)
if err != nil {
return nil, err
}
return p.publishReuseEndpoint(ctx, name, cfg, env, progress)
}

// publishReuseEndpoint resolves the reuse endpoint against env and
// writes it to the azd environment for agents to consume. It never
// contacts the toolbox data plane, so reusing a toolbox does not
// create a new version. Split from deployReuse so the publish path
// is unit-testable without a live azd environment.
func (p *toolboxServiceTarget) publishReuseEndpoint(
ctx context.Context,
name string,
cfg *toolboxServiceConfig,
env map[string]string,
progress azdext.ProgressReporter,
) (*azdext.ServiceDeployResult, error) {
resolved, err := resolveReuseEndpoint(name, cfg, env)
if err != nil {
return nil, err
}

if progress != nil {
progress(fmt.Sprintf("Reusing existing toolbox %q", name))
}

if err := setToolboxEndpointEnvFunc(ctx, name, resolved); err != nil {
return nil, err
}
return &azdext.ServiceDeployResult{}, nil
}

// resolveReuseEndpoint validates a reuse (bring-your-own)
// toolbox entry and returns its ${VAR}-expanded endpoint.
// Because a toolbox version is immutable, endpoint must not be
// combined with tools or a description; an endpoint that
// resolves to empty is also rejected.
func resolveReuseEndpoint(
name string, cfg *toolboxServiceConfig, env map[string]string,
) (string, error) {
if len(cfg.Tools) > 0 || strings.TrimSpace(cfg.Description) != "" {
return "", exterrors.Validation(
exterrors.CodeInvalidParameter,
fmt.Sprintf(
"toolbox %q sets 'endpoint' together with 'tools'/'description'",
name,
),
"set 'endpoint' to reuse an existing toolbox, or remove it to "+
"create a new version from 'tools'",
)
}

resolved, err := foundry.ExpandEnv(
cfg.Endpoint, func(k string) string { return env[k] },
)
if err != nil {
return "", exterrors.Validation(
exterrors.CodeInvalidParameter,
fmt.Sprintf("resolving 'endpoint' for toolbox %q: %s", name, err),
"check the ${VAR} references in 'endpoint'",
)
}

resolved = strings.TrimSpace(resolved)
if resolved == "" {
return "", exterrors.Validation(
exterrors.CodeInvalidParameter,
fmt.Sprintf("toolbox %q 'endpoint' resolved to an empty value", name),
"set 'endpoint' to an existing toolbox MCP endpoint (see "+
"'azd ai toolbox show')",
)
}
return resolved, nil
}

// buildToolEntries renders each declared tool into a data-plane tool object: ${VAR}
// references are expanded and a tool naming a `connection` has that name resolved to a
// project_connection_id (and server_url when the connection exposes a target).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,101 @@ func TestParseToolboxServiceConfig_ServiceLevel(t *testing.T) {
assert.Equal(t, "github-mcp", cfg.Tools[1]["connection"])
}

func TestParseToolboxServiceConfig_Endpoint(t *testing.T) {
t.Parallel()

props, err := structpb.NewStruct(map[string]any{
"endpoint": "${RESEARCH_TOOLBOX_ENDPOINT}",
})
require.NoError(t, err)

cfg, err := parseToolboxServiceConfig(&azdext.ServiceConfig{
Name: "research",
Host: aiToolboxHost,
AdditionalProperties: props,
})
require.NoError(t, err)
assert.Equal(t, "${RESEARCH_TOOLBOX_ENDPOINT}", cfg.Endpoint)
assert.Empty(t, cfg.Tools)
}

func TestResolveReuseEndpoint_ExpandsVar(t *testing.T) {
t.Parallel()

env := map[string]string{
"RESEARCH_TOOLBOX_ENDPOINT": "https://acct.services.ai.azure.com/api/projects/p/toolboxes/research/versions/3/mcp?api-version=v1",
}
cfg := &toolboxServiceConfig{Endpoint: "${RESEARCH_TOOLBOX_ENDPOINT}"}

got, err := resolveReuseEndpoint("research", cfg, env)
require.NoError(t, err)
assert.Equal(t, env["RESEARCH_TOOLBOX_ENDPOINT"], got)
}

func TestResolveReuseEndpoint_PlainEndpoint(t *testing.T) {
t.Parallel()

cfg := &toolboxServiceConfig{Endpoint: "https://mcp.example.com/toolboxes/research/versions/1/mcp"}

got, err := resolveReuseEndpoint("research", cfg, nil)
require.NoError(t, err)
assert.Equal(t, cfg.Endpoint, got)
}

func TestResolveReuseEndpoint_RejectsToolsWithEndpoint(t *testing.T) {
t.Parallel()

cfg := &toolboxServiceConfig{
Endpoint: "https://mcp.example.com/toolboxes/research/versions/1/mcp",
Tools: []map[string]any{{"type": "web_search"}},
}

_, err := resolveReuseEndpoint("research", cfg, nil)
require.Error(t, err)
}

func TestResolveReuseEndpoint_RejectsDescriptionWithEndpoint(t *testing.T) {
t.Parallel()

cfg := &toolboxServiceConfig{
Endpoint: "https://mcp.example.com/toolboxes/research/versions/1/mcp",
Description: "reused tools",
}

_, err := resolveReuseEndpoint("research", cfg, nil)
require.Error(t, err)
}

func TestResolveReuseEndpoint_RejectsEmptyResolved(t *testing.T) {
t.Parallel()

// ${MISSING} expands to empty when env does not define it.
cfg := &toolboxServiceConfig{Endpoint: "${MISSING}"}

_, err := resolveReuseEndpoint("research", cfg, map[string]string{})
require.Error(t, err)
}

func TestPublishReuseEndpoint_WritesExpandedEndpoint(t *testing.T) {
// No t.Parallel: stubToolboxEndpointEnv swaps a package-level seam.
const wantURL = "https://acct.services.ai.azure.com/api/projects/p/toolboxes/research/versions/3/mcp?api-version=v1"

calls := stubToolboxEndpointEnv(t)

// A zero-value target proves the reuse path never builds a toolbox
// client or creates a version (it holds no azd client or resolver).
tgt := &toolboxServiceTarget{}
cfg := &toolboxServiceConfig{Endpoint: "${RESEARCH_TOOLBOX_ENDPOINT}"}
env := map[string]string{"RESEARCH_TOOLBOX_ENDPOINT": wantURL}

res, err := tgt.publishReuseEndpoint(t.Context(), "research", cfg, env, nil)
require.NoError(t, err)
require.NotNil(t, res)
require.Len(t, *calls, 1)
assert.Equal(t, "research", (*calls)[0].name)
assert.Equal(t, wantURL, (*calls)[0].value)
}

func TestBuildToolEntries_ResolvesConnectionRef(t *testing.T) {
t.Parallel()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,14 @@
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://raw.githubusercontent.com/Azure/azure-dev/main/cli/azd/extensions/azure.ai.toolboxes/schemas/azure.ai.toolbox.json",
"title": "Azure AI Foundry toolbox service",
"description": "Service-level configuration for a host: azure.ai.toolbox entry. The service key is the toolbox name. A toolbox is a named bundle of connection-backed tools that agents reference by name.",
"description": "Service-level configuration for a host: azure.ai.toolbox entry. The service key is the toolbox name. A toolbox is a named bundle of connection-backed tools that agents reference by name. When endpoint is omitted, azd creates a new toolbox version from tools; when set, azd reuses that existing toolbox instead of creating one.",
"type": "object",
"additionalProperties": true,
"properties": {
"endpoint": {
"type": "string",
"description": "MCP endpoint URL of an existing Foundry toolbox version, as shown by 'azd ai toolbox show' (e.g. https://my-account.services.ai.azure.com/api/projects/my-project/toolboxes/research/versions/3/mcp?api-version=v1). When set, azd reuses this existing toolbox and publishes the endpoint for agents instead of creating a new version, so 'tools' and 'description' must be omitted. May contain ${VAR} (azd env, resolved client-side)."
},
"description": {
"type": "string",
"description": "Description of the toolbox."
Expand All @@ -29,5 +33,19 @@
}
}
}
}
},
"allOf": [
{
"$comment": "Reuse mode: a toolbox version is immutable, so 'endpoint' cannot be combined with 'tools' or 'description'.",
"if": {
"required": ["endpoint"]
},
"then": {
"properties": {
"tools": false,
"description": false
}
}
}
]
}
Loading