Skip to content

Latest commit

 

History

History
554 lines (444 loc) · 18.8 KB

extension-e2e-walkthrough.md

File metadata and controls

554 lines (444 loc) · 18.8 KB

Extension End-to-End Walkthrough

Build a complete azd extension from scratch using the azdext SDK helpers. This walkthrough creates a resource-tagging extension that:

  1. Registers custom commands for managing Azure resource tags.
  2. Exposes an MCP server so AI assistants can call the tagging tools.
  3. Hooks into the postprovision lifecycle event to auto-tag resources.
  4. Uses MCPSecurityPolicy to validate user-supplied URLs.

Prerequisites:

  • Go ≥ 1.22
  • azd ≥ 1.23.7 (with extension SDK helpers from #6856)
  • The microsoft.azd.extensions developer extension installed (azd extension install microsoft.azd.extensions)

Table of Contents


Step 1: Scaffold the Extension

cd cli/azd/extensions
azd x init

Follow 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-events

Step 2: Define the Root Command

Replace 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.

Step 3: Add a Custom Command

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.
  • ConfigHelper for reading azd environment configuration.
  • LocalError with category and suggestion for structured error reporting.
  • Output for format-aware display (text or JSON).
  • Logger for structured logging.

Step 4: Build an MCP Server with Tools

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:

  • MCPServerBuilder fluent API with rate limiting and an attached security policy.
  • ToolArgs.RequireString / OptionalString for typed argument access.
  • MCPTextResult, MCPJSONResult, MCPErrorResult for response construction.
  • Explicit, handler-owned URL validation with SecurityPolicy().CheckURL before using user-provided URLs. Attaching DefaultMCPSecurityPolicy alone 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.

Step 5: Register Lifecycle Event Handlers

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 ProjectEventArgs with access to the project metadata and gRPC client.
  • Non-fatal error handling — the handler logs warnings but doesn't block the parent azd workflow.

Step 6: Wire It All Together

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_COLOR detection
  • Trace context propagation
  • Access token injection
  • Structured error reporting
  • Exit code management

Step 7: Build, Install, and Test

# 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 fires

Project Structure Summary

contoso.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

What You Have Built

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

See Also