Skip to content
Open
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
48 changes: 48 additions & 0 deletions cli/azd/extensions/azure.ai.toolboxes/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@ Toolbox-owned authoring properties use camelCase, including
pass-through payloads and retain the field names and discriminator values defined
by the corresponding service contract.

Connection-backed tools use the connection's name as the tool name. Names must
be unique across `connections[].name` and explicit `tools[].name` values.
Create and managed deployment reject duplicates before endpoint resolution or
access preparation.

Configure a Foundry project endpoint before deploying. If the project or
referenced Connections are also managed as services, declare those dependencies
with `uses`. Run `azd provision` for the project, then `azd deploy --all` for
Expand All @@ -67,6 +72,49 @@ For direct resource operations:
Removing a service from `azure.yaml` stops managing the toolbox but does not
delete the remote resource; use `azd ai toolbox delete` to delete it.

## Developer access for deployment and create

Managed toolbox deployment and `azd ai toolbox create` prepare Foundry access
independently of any agent predeploy hook. Subscription **Owner** or
**Contributor** alone does not grant data-plane access. After local input
validation, the extension probes toolbox read access before resolving remote
connections or creating a version. A successful read or a toolbox-not-found
response does not trigger a role assignment.

Automatic grants require `AZURE_AI_PROJECT_ID` to identify the project selected
by `FOUNDRY_PROJECT_ENDPOINT` (or its `AZURE_AI_PROJECT_ENDPOINT` fallback).
The operation endpoint must match that project, including when explicitly
selected with `--project-endpoint`. If `AZURE_SUBSCRIPTION_ID` is set, it must
agree with the project ID. Unrelated endpoints and endpoint-only usage do not
borrow the active project's scope or tenant and do not receive automatic grants.
Managed deployment retains its existing subscription/user-tenant credential
fallback when matching project metadata is unavailable.

Only a confirmed authorization denial triggers inherited/equivalent role
inspection and, if needed, a project-scoped **Foundry User** assignment.
Credentials use the subscription's user-access tenant; principal lookup uses
the project's resource tenant, including for guest users and service principals.
New grants are followed by bounded readiness checks. If propagation takes longer,
wait and retry. Inspection or assignment failures produce a warning and let the
requested operation determine the final result. An assignment-denied warning
includes an administrator command for the verified project scope.

Set `AZD_AGENT_SKIP_ROLE_ASSIGNMENTS=true` in the active azd environment or
process environment to disable these automatic checks and grants. A non-empty
azd environment value takes precedence; an absent or empty value falls back to
the process environment. Values are parsed as booleans; invalid values do not
enable skipping. Existing externally managed access is still required.

Warnings and grant/propagation progress go to stderr, preserving JSON results
on stdout. Read/list/show, delete, connection and skill mutations, publish,
local authoring, and bring-your-own endpoint reuse do not prepare access.
Provisioning remains independent of developer role-assignment permission.

The adapter uses core `pkg/foundry.AccessPreparation`. Release the core helper
and bump this extension's azd dependency before merging/releasing the extension;
an external Go workspace can validate both modules before that release without
committing a local `replace` or inventing a dependency version.

## Extension telemetry API

Extension code can report best-effort usage events through the shared
Expand Down
1 change: 1 addition & 0 deletions cli/azd/extensions/azure.ai.toolboxes/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ go 1.26.4
require (
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.23.3
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.15.0
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/authorization/armauthorization/v2 v2.2.0
github.com/azure/azure-dev/cli/azd v1.37.0
github.com/fatih/color v1.19.0
github.com/spf13/cobra v1.10.2
Expand Down
2 changes: 2 additions & 0 deletions cli/azd/extensions/azure.ai.toolboxes/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ github.com/Azure/azure-sdk-for-go/sdk/internal v1.13.0 h1:Terj1CMW4+NY5CpWjSRkqL
github.com/Azure/azure-sdk-for-go/sdk/internal v1.13.0/go.mod h1:7Ut2Qw9vcwpLW5Q4MBy43uSxXS7tK2yjs94OJ6j5thE=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/appservice/armappservice/v2 v2.3.0 h1:JI8PcWOImyvIUEZ0Bbmfe05FOlWkMi2KhjG+cAKaUms=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/appservice/armappservice/v2 v2.3.0/go.mod h1:nJLFPGJkyKfDDyJiPuHIXsCi/gpJkm07EvRgiX7SGlI=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/authorization/armauthorization/v2 v2.2.0 h1:Hp+EScFOu9HeCbeW8WU2yQPJd4gGwhMgKxWe+G6jNzw=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/authorization/armauthorization/v2 v2.2.0/go.mod h1:/pz8dyNQe+Ey3yBp/XuYz7oqX8YDNWVpPB0hH3XWfbc=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v2 v2.0.0 h1:PTFGRSlMKCQelWwxUyYVEUqseBJVemLyqWJjvMyt0do=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v2 v2.0.0/go.mod h1:LRr2FzBTQlONPPa5HREE5+RjSCTXl7BwOvYOaWTqCaI=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v3 v3.1.0 h1:2qsIIvxVT+uE6yrNldntJKlLRgxGbZ85kgtz5SNBhMw=
Expand Down
59 changes: 58 additions & 1 deletion cli/azd/extensions/azure.ai.toolboxes/internal/cmd/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,19 @@ import (
"context"
"errors"
"fmt"
"io"
"log"
"net/http"
"regexp"
"strings"

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

"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/foundry"
"github.com/spf13/cobra"
)

Expand All @@ -36,6 +37,24 @@ var toolboxNamePattern = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
// maxToolboxNameLength caps positional names.
const maxToolboxNameLength = 63

func validateToolboxWriteFlags(cmd *cobra.Command) error {
for _, name := range []string{"project-endpoint", "from-file"} {
if !cmd.Flags().Changed(name) {
continue
}
value, err := cmd.Flags().GetString(name)
if err != nil {
return err
}
if strings.TrimSpace(value) == "" {
return exterrors.Validation(exterrors.CodeInvalidParameter,
fmt.Sprintf("--%s must not be empty when supplied", name),
"provide a non-empty value, or omit the flag if it is optional")
}
}
return nil
}

// readToolboxFlags extracts the persistent flag values from a subcommand. The
// reserved azd globals `--output` and `--no-prompt` come from extCtx. `output`
// is normalized to lowercase so downstream branches can compare with `== "json"`.
Expand Down Expand Up @@ -120,6 +139,18 @@ func newToolboxClientForTenant(endpoint, tenantID string) (*azure.FoundryToolbox
return azure.NewFoundryToolboxClient(endpoint, cred), nil
}

func toolboxTenantID(ctx context.Context, account foundry.TenantResolver, subscriptionID string) (string, error) {
tenantID, err := foundry.LookupProjectTenant(ctx, account, subscriptionID)
if cancelErr := foundry.AccessCancellation(ctx, err); cancelErr != nil {
return "", cancelErr
}
if err != nil {
return "", exterrors.Auth(exterrors.CodeTenantLookupFailed,
"could not resolve the Foundry project's user-access tenant", "run 'azd auth login' and retry")
}
return tenantID, nil
}

// resolveToolboxAndClient walks the endpoint cascade, validates flags, and
// returns a toolbox client bound to the resolved endpoint.
func resolveToolboxAndClient(
Expand All @@ -139,6 +170,32 @@ func resolveToolboxAndClient(
return client, resolved, nil
}

// resolveToolboxWriteAndClient is deliberately separate from read/delete resolution.
func resolveToolboxWriteAndClient(
ctx context.Context, flags toolboxFlags, name string, writer io.Writer,
) (toolboxClient, *projectctx.Resolved, error) {
if err := validateOutputFormat(flags.output); err != nil {
return nil, nil, err
}
resolved, err := projectctx.Resolve(ctx, projectctx.ResolveOpts{FlagValue: flags.projectEndpoint})
if err != nil {
return nil, nil, err
}
host, err := azdext.NewAzdClient()
if err != nil {
fmt.Fprintln(writer, "Warning: No azd host is available; Foundry roles will not be assigned automatically.")
client, clientErr := newToolboxClient(resolved.Endpoint)
return client, resolved, clientErr
}
defer host.Close()
access := newToolboxAccess(host)
access.writer = writer
client, err := access.writeClient(ctx, resolved.Endpoint, name, func(endpoint, tenantID string) (toolboxClient, error) {
return newToolboxClientForTenant(endpoint, tenantID)
})
return client, resolved, err
}

// isAzureNotFound reports whether err originates from an Azure response with HTTP 404.
func isAzureNotFound(err error) bool {
if err == nil {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,11 @@ A toolbox is a versioned, named collection of connection-backed tools that
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.`,
to promote a version.

Managed deployment and 'create' check Foundry access and may assign Foundry User
when the target matches the active environment's project resource ID and endpoint.
Other commands do not assign roles. Provisioning does not assign developer access.`,
})

rootCmd.SilenceUsage = true
Expand Down
104 changes: 55 additions & 49 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,6 +7,7 @@ import (
"context"
"encoding/json"
"fmt"
"maps"
"strings"

"azure.ai.toolboxes/internal/definition"
Expand Down Expand Up @@ -52,10 +53,11 @@ type toolboxServiceConfig struct {
// artifact.
type toolboxServiceTarget struct {
environmentClient environmentReader
accountClient accountTenantLookup
accountClient foundry.TenantResolver
projectClient serviceConfigReader
resolver connectionResolver
newClient func(endpoint, tenantID string) (toolboxClient, error)
access *toolboxAccess
}

// newToolboxServiceTarget creates the azure.ai.toolbox service-target provider.
Expand All @@ -67,6 +69,7 @@ func newToolboxServiceTarget(
accountClient: azdClient.Account(),
projectClient: azdClient.Project(),
resolver: defaultConnectionResolver{},
access: newToolboxAccess(azdClient),
newClient: func(endpoint, tenantID string) (toolboxClient, error) {
return newToolboxClientForTenant(endpoint, tenantID)
},
Expand Down Expand Up @@ -157,47 +160,61 @@ func (p *toolboxServiceTarget) Deploy(
return p.deployReuse(ctx, name, cfg, serviceConfig, progress)
}

resolved, err := projectctx.Resolve(ctx, projectctx.ResolveOpts{})
if err != nil {
return nil, err
}
endpoint := resolved.Endpoint

environment, err := p.environmentValues(ctx, serviceConfig)
if err != nil {
return nil, err
}
rawTools, err := p.buildToolEntries(
ctx,
endpoint,
cfg.Tools,
environment,
)
if err != nil {
if err := validateToolboxName(name); err != nil {
return nil, err
}
request, err := buildToolboxVersionRequest(ctx, p.resolver, endpoint, &definition.Definition{
tools := make([]map[string]any, 0, len(cfg.Tools))
for _, tool := range cfg.Tools {
expanded, _ := expandToolboxValue(tool, environment).(map[string]any)
tools = append(tools, expanded)
}
input := &definition.Definition{
Name: name,
Description: cfg.Description,
Connections: cfg.Connections,
Skills: cfg.Skills,
Tools: rawTools,
Tools: tools,
Policies: cfg.Policies,
Metadata: cfg.Metadata,
})
}
if err := validateToolboxDefinition(input); err != nil {
return nil, err
}

resolved, err := projectctx.Resolve(ctx, projectctx.ResolveOpts{})
if err != nil {
return nil, err
}
endpoint := resolved.Endpoint

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

tenantID, err := p.credentialTenantID(ctx)
var scope *toolboxAccessScope
if p.access != nil {
scope, err = p.access.resolve(ctx, endpoint)
if err != nil {
return nil, err
}
}
var tenantID string
if scope != nil {
tenantID, err = toolboxTenantID(ctx, p.accountClient, scope.resource.SubscriptionID)
} else {
tenantID, err = p.credentialTenantID(ctx)
}
if err != nil {
return nil, err
}
client, err := p.newClient(endpoint, tenantID)
if cancelErr := foundry.AccessCancellation(ctx, err); cancelErr != nil {
return nil, cancelErr
}
if err != nil {
return nil, exterrors.Auth(
exterrors.CodeCredentialCreationFailed,
Expand All @@ -206,9 +223,24 @@ func (p *toolboxServiceTarget) Deploy(
)
}

if p.access != nil {
client, err = p.access.prepare(ctx, scope, tenantID, client, name)
if err != nil {
return nil, err
}
}
input.Tools, err = p.buildToolEntries(ctx, endpoint, tools, nil)
if err != nil {
return nil, err
}
request, err := buildToolboxVersionRequest(ctx, p.resolver, endpoint, input)
if err != nil {
return nil, err
}

created, err := client.CreateToolboxVersion(ctx, name, request)
if err != nil {
return nil, fmt.Errorf("upserting toolbox %q: %w", name, err)
return nil, exterrors.ServiceFromAzure(err, exterrors.OpCreateToolboxVersion)
}

// Surface the toolbox's MCP endpoint to agents (and the developer) without re-running.
Expand Down Expand Up @@ -322,9 +354,9 @@ func (p *toolboxServiceTarget) buildToolEntries(
) ([]map[string]any, error) {
out := make([]map[string]any, 0, len(tools))
for _, tool := range tools {
entry, ok := expandToolboxValue(tool, env).(map[string]any)
if !ok {
continue
entry := maps.Clone(tool)
if env != nil {
entry, _ = expandToolboxValue(tool, env).(map[string]any)
}
if connName, isString := entry["connection"].(string); isString && connName != "" {
conn, err := p.resolver.resolveConnection(ctx, endpoint, connName)
Expand Down Expand Up @@ -446,14 +478,6 @@ type environmentReader interface {
) (*azdext.KeyValueResponse, error)
}

type accountTenantLookup interface {
LookupTenant(
ctx context.Context,
in *azdext.LookupTenantRequest,
opts ...grpc.CallOption,
) (*azdext.LookupTenantResponse, error)
}

func (p *toolboxServiceTarget) credentialTenantID(ctx context.Context) (string, error) {
current, err := p.environmentClient.GetCurrent(ctx, &azdext.EmptyRequest{})
if err != nil {
Expand Down Expand Up @@ -488,25 +512,7 @@ func (p *toolboxServiceTarget) credentialTenantID(ctx context.Context) (string,
"set AZURE_SUBSCRIPTION_ID in the active azd environment and retry",
)
}
tenantResponse, err := p.accountClient.LookupTenant(ctx, &azdext.LookupTenantRequest{
SubscriptionId: subscriptionID,
})
if err != nil {
return "", exterrors.Auth(
exterrors.CodeTenantLookupFailed,
fmt.Sprintf("failed to get tenant ID for subscription %s: %s", subscriptionID, err),
"verify your Azure login with 'azd auth login' and that you have access to this subscription",
)
}
tenantID := strings.TrimSpace(tenantResponse.GetTenantId())
if tenantID == "" {
return "", exterrors.Auth(
exterrors.CodeTenantLookupFailed,
fmt.Sprintf("azd returned an empty tenant ID for subscription %s", subscriptionID),
"verify your Azure login with 'azd auth login' and that you have access to this subscription",
)
}
return tenantID, nil
return toolboxTenantID(ctx, p.accountClient, subscriptionID)
}

func serviceEnvDeclared(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,10 @@ environment under the TOOLBOX_<NORMALIZED_NAME>_MCP_ENDPOINT variable (the same
key agents consume), where <NORMALIZED_NAME> is the toolbox name uppercased with
non-alphanumeric character runs replaced by underscores.

After local validation, checks Foundry access before resolving connections.
May assign Foundry User when the target matches the active environment's
project resource ID and endpoint. Access warnings and progress go to stderr.

Usage
azd ai toolbox create <name> --from-file <path> [flags]

Expand Down
Loading
Loading