From 10e73024cdf8b89474927a82ea6901b98fe22273 Mon Sep 17 00:00:00 2001 From: huimiu Date: Wed, 8 Jul 2026 18:04:02 +0800 Subject: [PATCH 1/2] feat(toolboxes): reuse existing toolbox via endpoint in azure.yaml --- .../azure.ai.toolboxes/CHANGELOG.md | 6 ++ .../extensions/azure.ai.toolboxes/README.md | 22 +++++ .../internal/cmd/service_target.go | 90 +++++++++++++++++++ .../internal/cmd/service_target_test.go | 75 ++++++++++++++++ .../schemas/azure.ai.toolbox.json | 22 ++++- 5 files changed, 213 insertions(+), 2 deletions(-) diff --git a/cli/azd/extensions/azure.ai.toolboxes/CHANGELOG.md b/cli/azd/extensions/azure.ai.toolboxes/CHANGELOG.md index 55a3c30ad55..ee0e7966e68 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/CHANGELOG.md +++ b/cli/azd/extensions/azure.ai.toolboxes/CHANGELOG.md @@ -1,5 +1,11 @@ # Release History +## Unreleased + +### Features Added + +- The `azure.ai.toolbox` service now accepts an `endpoint` field to reuse an existing toolbox instead of creating a new version on `azd deploy`. When set, azd resolves `${VAR}` references and publishes the endpoint to the azd environment for agents to consume; `endpoint` is mutually exclusive with `tools` and `description`. This mirrors the `azure.ai.project` brownfield `endpoint` (omit to create, set to reuse). + ## 1.0.0-beta.1 (2026-06-30) ### Features Added diff --git a/cli/azd/extensions/azure.ai.toolboxes/README.md b/cli/azd/extensions/azure.ai.toolboxes/README.md index 1b2c0858f8f..406294ec0ee 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/README.md +++ b/cli/azd/extensions/azure.ai.toolboxes/README.md @@ -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 +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 ` (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`. + diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/service_target.go b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/service_target.go index 69dd7e27075..0d7f9ce9ec0 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/service_target.go +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/service_target.go @@ -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" @@ -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"` } @@ -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, @@ -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 @@ -164,6 +180,80 @@ 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 + } + + 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). diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/service_target_test.go b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/service_target_test.go index 2c08f4c949c..3f6484184f5 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/service_target_test.go +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/service_target_test.go @@ -50,6 +50,81 @@ 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 against an env that does not define it. + cfg := &toolboxServiceConfig{Endpoint: "${MISSING}"} + + _, err := resolveReuseEndpoint("research", cfg, map[string]string{}) + require.Error(t, err) +} + func TestBuildToolEntries_ResolvesConnectionRef(t *testing.T) { t.Parallel() diff --git a/cli/azd/extensions/azure.ai.toolboxes/schemas/azure.ai.toolbox.json b/cli/azd/extensions/azure.ai.toolboxes/schemas/azure.ai.toolbox.json index 46b35dbfd68..c3a8f83d718 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/schemas/azure.ai.toolbox.json +++ b/cli/azd/extensions/azure.ai.toolboxes/schemas/azure.ai.toolbox.json @@ -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." @@ -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 + } + } + } + ] } From 40c90cd3234f0042cfc7278df1cb92237ffefd1b Mon Sep 17 00:00:00 2001 From: huimiu Date: Wed, 8 Jul 2026 18:13:51 +0800 Subject: [PATCH 2/2] test: cover toolbox reuse deploy publish path --- .../internal/cmd/service_target.go | 14 ++++++++++++ .../internal/cmd/service_target_test.go | 22 ++++++++++++++++++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/service_target.go b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/service_target.go index 0d7f9ce9ec0..d97321acaeb 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/service_target.go +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/service_target.go @@ -195,7 +195,21 @@ func (p *toolboxServiceTarget) deployReuse( 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 diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/service_target_test.go b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/service_target_test.go index 3f6484184f5..8fece33c91a 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/service_target_test.go +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/service_target_test.go @@ -118,13 +118,33 @@ func TestResolveReuseEndpoint_RejectsDescriptionWithEndpoint(t *testing.T) { func TestResolveReuseEndpoint_RejectsEmptyResolved(t *testing.T) { t.Parallel() - // ${MISSING} expands to empty against an env that does not define it. + // ${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()