Build a complete azd extension from scratch using the azdext SDK helpers. This
walkthrough creates a resource-tagging extension that:
- Registers custom commands for managing Azure resource tags.
- Exposes an MCP server so AI assistants can call the tagging tools.
- Hooks into the
postprovisionlifecycle event to auto-tag resources. - Uses
MCPSecurityPolicyto validate user-supplied URLs.
Prerequisites:
- Go ≥ 1.22
- azd ≥ 1.23.7 (with extension SDK helpers from #6856)
- The
microsoft.azd.extensionsdeveloper extension installed (azd extension install microsoft.azd.extensions)
- Step 1: Scaffold the Extension
- Step 2: Define the Root Command
- Step 3: Add a Custom Command
- Step 4: Build an MCP Server with Tools
- Step 5: Register Lifecycle Event Handlers
- Step 6: Wire It All Together
- Step 7: Build, Install, and Test
- Project Structure Summary
- What You Have Built
cd cli/azd/extensions
azd x initFollow the prompts:
- Name:
contoso.azd.tagger - Language: Go
- Capabilities:
custom-commands,metadata,lifecycle-events
This creates a directory with extension.yaml, main.go, build scripts, and a
CHANGELOG.md.
Edit extension.yaml to match:
# yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/refs/heads/main/cli/azd/extensions/extension.schema.json
id: contoso.azd.tagger
namespace: tagger
displayName: Resource Tagger
description: Auto-tag Azure resources and expose tagging tools via MCP.
version: 0.1.0
capabilities:
- custom-commands
- metadata
- lifecycle-eventsReplace main.go with the SDK-helper entry point:
package main
import (
"github.com/azure/azure-dev/cli/azd/extensions/contoso.azd.tagger/internal/cmd"
"github.com/azure/azure-dev/cli/azd/pkg/azdext"
)
func main() {
azdext.Run(cmd.NewRootCommand())
}Create internal/cmd/root.go:
package cmd
import (
"github.com/azure/azure-dev/cli/azd/pkg/azdext"
"github.com/spf13/cobra"
)
const (
extensionID = "contoso.azd.tagger"
version = "0.1.0"
)
func NewRootCommand() *cobra.Command {
// NewExtensionRootCommand registers --debug, --no-prompt, --cwd,
// -e/--environment, --output and sets up trace context automatically.
rootCmd, extCtx := azdext.NewExtensionRootCommand(azdext.ExtensionCommandOptions{
Name: "tagger",
Version: version,
Short: "Manage Azure resource tags",
})
// Custom commands
rootCmd.AddCommand(newTagCommand(extCtx))
rootCmd.AddCommand(newMCPCommand(extCtx))
// Standard lifecycle, metadata, and version commands
rootCmd.AddCommand(azdext.NewListenCommand(configureListen))
rootCmd.AddCommand(azdext.NewMetadataCommand("1.0", extensionID, NewRootCommand))
rootCmd.AddCommand(azdext.NewVersionCommand(extensionID, version, &extCtx.OutputFormat))
return rootCmd
}What this gives you:
- All azd global flags parsed and available via
extCtx. - OpenTelemetry trace context propagated from the parent azd process.
- gRPC access token injected into the command context.
Create internal/cmd/tag.go:
package cmd
import (
"fmt"
"github.com/azure/azure-dev/cli/azd/pkg/azdext"
"github.com/spf13/cobra"
)
func newTagCommand(extCtx *azdext.ExtensionContext) *cobra.Command {
var tagKey, tagValue string
cmd := &cobra.Command{
Use: "tag",
Short: "Apply a tag to resources in the current environment",
RunE: func(cmd *cobra.Command, args []string) error {
ctx := extCtx.Context()
// Create the azd gRPC client
client, err := azdext.NewAzdClient()
if err != nil {
return fmt.Errorf("creating azd client: %w", err)
}
defer client.Close()
// Use the ConfigHelper to read environment config
configHelper, err := azdext.NewConfigHelper(client)
if err != nil {
return fmt.Errorf("creating config helper: %w", err)
}
// Read the resource group from env config
rg, found, err := configHelper.GetEnvString(ctx, "AZURE_RESOURCE_GROUP")
if err != nil {
return fmt.Errorf("reading resource group: %w", err)
}
if !found {
return &azdext.LocalError{
Message: "no resource group configured",
Category: azdext.LocalErrorCategoryValidation,
Suggestion: "Run 'azd provision' first to create Azure resources.",
}
}
// Format-aware output
output := azdext.NewOutput(azdext.OutputOptions{
Format: azdext.OutputFormat(extCtx.OutputFormat),
})
// Log the operation
logger := azdext.NewLogger("tagger")
logger.Info("applying tag", "key", tagKey, "value", tagValue, "rg", rg)
// ... tag application logic using Azure SDK ...
output.Success("Tagged resources in %s: %s=%s", rg, tagKey, tagValue)
return nil
},
}
cmd.Flags().StringVar(&tagKey, "key", "", "Tag key (required)")
cmd.Flags().StringVar(&tagValue, "value", "", "Tag value (required)")
_ = cmd.MarkFlagRequired("key")
_ = cmd.MarkFlagRequired("value")
return cmd
}Key patterns demonstrated:
extCtx.Context()for a trace-aware, token-injected context.ConfigHelperfor reading azd environment configuration.LocalErrorwith category and suggestion for structured error reporting.Outputfor format-aware display (text or JSON).Loggerfor structured logging.
Create internal/cmd/mcp.go:
For the optional audit webhook, replace audit.example.com below with an HTTPS
service you operate or explicitly trust, including its DNS administration. Keep
this hostname fixed in the extension, not supplied by tool arguments or an AI
client. The handler accepts only that host on the default HTTPS port and does
not follow redirects. This trust boundary matters because CheckURL checks DNS
before the HTTP transport resolves it again; it does not pin the dial address
or protect against DNS rebinding by an attacker who controls an allowed host.
package cmd
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/url"
"os"
"time"
"github.com/azure/azure-dev/cli/azd/pkg/azdext"
mcp "github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"github.com/spf13/cobra"
)
func newMCPCommand(extCtx *azdext.ExtensionContext) *cobra.Command {
return &cobra.Command{
Use: "mcp",
Short: "Start the MCP server for AI-assisted tagging",
RunE: func(cmd *cobra.Command, args []string) error {
// Build the MCP server with the fluent builder API
builder := azdext.NewMCPServerBuilder("tagger", "0.1.0").
// Rate limit: max 10 concurrent, refill 2/sec
WithRateLimit(10, 2.0).
// Security policy to validate any user-provided URLs
WithSecurityPolicy(azdext.DefaultMCPSecurityPolicy()).
// System instructions for AI clients
WithInstructions(`Use these tools to manage Azure resource tags.
Always confirm tag operations with the user before applying.`)
// Register tools. The set_tag handler closes over the builder so it
// can explicitly validate URL arguments with the configured policy.
mcpServer := builder.AddTool("list_tags", listTagsHandler, azdext.MCPToolOptions{
Description: "List tags on resources in a resource group",
},
mcp.WithString("resourceGroup",
mcp.Required(),
mcp.Description("Azure resource group name"),
),
mcp.WithString("subscription",
mcp.Description("Azure subscription ID (uses default if omitted)"),
),
).
AddTool("set_tag", newSetTagHandler(builder), azdext.MCPToolOptions{
Description: "Set a tag on all resources in a resource group",
Destructive: true,
},
mcp.WithString("resourceGroup",
mcp.Required(),
mcp.Description("Azure resource group name"),
),
mcp.WithString("key",
mcp.Required(),
mcp.Description("Tag key"),
),
mcp.WithString("value",
mcp.Required(),
mcp.Description("Tag value"),
),
mcp.WithString("auditWebhookUrl",
mcp.Description("HTTPS audit.example.com webhook for the completed tag operation"),
),
).
Build()
// Serve over stdio (standard MCP transport)
sseServer := server.NewStdioServer(mcpServer)
return sseServer.Listen(cmd.Context(), os.Stdin, os.Stdout)
},
}
}
// listTagsHandler demonstrates typed argument parsing and JSON result helpers.
func listTagsHandler(ctx context.Context, args azdext.ToolArgs) (*mcp.CallToolResult, error) {
rg, err := args.RequireString("resourceGroup")
if err != nil {
return azdext.MCPErrorResult("missing argument: %v", err), nil
}
sub := args.OptionalString("subscription", "")
logger := azdext.NewLogger("mcp.list_tags")
logger.Info("listing tags", "resourceGroup", rg, "subscription", sub)
// ... Azure SDK call to list tags ...
tags := map[string]string{
"environment": "production",
"owner": "platform-team",
}
return azdext.MCPJSONResult(tags), nil
}
// newSetTagHandler demonstrates security policy usage and error handling.
func newSetTagHandler(builder *azdext.MCPServerBuilder) azdext.MCPToolHandler {
return func(ctx context.Context, args azdext.ToolArgs) (*mcp.CallToolResult, error) {
rg, err := args.RequireString("resourceGroup")
if err != nil {
return azdext.MCPErrorResult("missing argument: %v", err), nil
}
key, err := args.RequireString("key")
if err != nil {
return azdext.MCPErrorResult("missing argument: %v", err), nil
}
value, err := args.RequireString("value")
if err != nil {
return azdext.MCPErrorResult("missing argument: %v", err), nil
}
auditWebhookURL := args.OptionalString("auditWebhookUrl", "")
if auditWebhookURL != "" {
webhook, err := url.Parse(auditWebhookURL)
if err != nil || webhook.Scheme != "https" || webhook.User != nil ||
(webhook.Host != "audit.example.com" && webhook.Host != "audit.example.com:443") {
return azdext.MCPErrorResult(
"audit webhook must use the trusted HTTPS host audit.example.com on port 443 without embedded credentials"), nil
}
policy := builder.SecurityPolicy()
if policy == nil {
return azdext.MCPErrorResult("audit webhook URL validation is not configured"), nil
}
if err := policy.CheckURL(auditWebhookURL); err != nil {
return azdext.MCPErrorResult("audit webhook URL blocked by security policy"), nil
}
}
logger := azdext.NewLogger("mcp.set_tag")
logger.Info("setting tag", "resourceGroup", rg, "key", key, "value", value)
// ... Azure SDK call to set tag ...
if auditWebhookURL != "" {
payload, err := json.Marshal(map[string]string{
"resourceGroup": rg,
"key": key,
"value": value,
})
if err != nil {
return azdext.MCPErrorResult("encoding audit webhook payload: %v", err), nil
}
req, err := http.NewRequestWithContext(
ctx, http.MethodPost, auditWebhookURL, bytes.NewReader(payload))
if err != nil {
return azdext.MCPErrorResult("creating audit webhook request failed"), nil
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{
Timeout: 10 * time.Second,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
resp, err := client.Do(req)
if err != nil {
return azdext.MCPErrorResult("sending audit webhook failed or timed out"), nil
}
defer resp.Body.Close()
if resp.StatusCode >= http.StatusMultipleChoices {
return azdext.MCPErrorResult(
"audit webhook returned unsuccessful status %d (redirects are not followed)", resp.StatusCode), nil
}
}
return azdext.MCPTextResult(
"Tag %s=%s applied to resource group %s", key, value, rg), nil
}
}Key patterns demonstrated:
MCPServerBuilderfluent API with rate limiting and an attached security policy.ToolArgs.RequireString/OptionalStringfor typed argument access.MCPTextResult,MCPJSONResult,MCPErrorResultfor response construction.- Explicit, handler-owned URL validation with
SecurityPolicy().CheckURLbefore using user-provided URLs. AttachingDefaultMCPSecurityPolicyalone does not inspect tool arguments or enforce SSRF protection. - A fixed trusted webhook host, no redirects, and a 10-second HTTP timeout. Webhook failures are tool errors; they do not undo the already completed tag operation. Errors omit raw URLs and underlying HTTP errors to avoid exposing embedded URL credentials or query tokens.
Create internal/cmd/listen.go:
package cmd
import (
"context"
"fmt"
"github.com/azure/azure-dev/cli/azd/pkg/azdext"
)
// configureListen is called by NewListenCommand to register event handlers.
func configureListen(host *azdext.ExtensionHost) {
host.WithProjectEventHandler("postprovision", handlePostProvision)
}
func handlePostProvision(ctx context.Context, args *azdext.ProjectEventArgs) error {
logger := azdext.NewLogger("tagger.postprovision")
logger.Info("auto-tagging resources after provision", "project", args.Project.Name)
client := args.Client // The host provides access to the azd gRPC client
// Read environment values
envResp, err := client.Environment().GetValues(ctx, &azdext.GetEnvironmentRequest{})
if err != nil {
logger.Warn("could not read environment", "error", err)
return nil // Non-fatal: don't block provisioning
}
// Apply standard tags to all resources
for key, value := range envResp.Values {
logger.Debug("found env value", "key", key, "value", value)
}
logger.Info("auto-tagging complete")
return nil
}Key patterns demonstrated:
NewListenCommand+ configure callback for clean lifecycle registration.- Project event handlers receive
ProjectEventArgswith access to the project metadata and gRPC client. - Non-fatal error handling — the handler logs warnings but doesn't block the parent azd workflow.
Your final main.go:
package main
import (
"github.com/azure/azure-dev/cli/azd/extensions/contoso.azd.tagger/internal/cmd"
"github.com/azure/azure-dev/cli/azd/pkg/azdext"
)
func main() {
azdext.Run(cmd.NewRootCommand())
}That is the entire entry point. azdext.Run handles:
FORCE_COLORdetection- Trace context propagation
- Access token injection
- Structured error reporting
- Exit code management
# Build for current platform
azd x build
# Build for all platforms
azd x build --all
# Test the custom command
azd tagger tag --key team --value platform -e dev
# Test the MCP server (connects over stdio)
echo '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | azd tagger mcp
# Test lifecycle integration (azd invokes 'listen' automatically)
azd provision # postprovision handler firescontoso.azd.tagger/
├── main.go # Entry point: azdext.Run(cmd.NewRootCommand())
├── extension.yaml # Extension manifest
├── CHANGELOG.md # Release notes
├── go.mod # Go module
├── go.sum
├── build.ps1 # Windows build
├── build.sh # Unix build
└── internal/
└── cmd/
├── root.go # Root command + subcommand wiring
├── tag.go # Custom 'tag' command
├── mcp.go # MCP server with tools
└── listen.go # Lifecycle event handlers
| Capability | SDK Helper Used | Lines Saved |
|---|---|---|
| Root command with global flags + tracing | NewExtensionRootCommand |
~40 lines |
| Entry point with error reporting | Run |
~15 lines |
| Listen command with host setup | NewListenCommand |
~20 lines |
| MCP server with rate limiting + security | MCPServerBuilder |
~60 lines |
| Typed MCP argument parsing | ToolArgs |
~10 lines per tool |
| MCP response construction | MCPTextResult, MCPJSONResult, MCPErrorResult |
~5 lines per tool |
| SSRF/path protection | DefaultMCPSecurityPolicy |
~50 lines |
| Metadata + version commands | NewMetadataCommand, NewVersionCommand |
~30 lines |
| Total boilerplate eliminated | ~250+ lines |
- Extension SDK Reference — Full API reference for all helpers.
- Extension Migration Guide — Migrate existing extensions from legacy patterns.
- Extension Framework — General framework documentation.
- Extension Framework Services — gRPC service reference.
- Extension Style Guide — Design guidelines.