From 2675072ce903dd1a4e777492f1b97bb709604a6d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 31 Mar 2026 20:06:41 +0000 Subject: [PATCH] docs: add extension SDK reference, e2e walkthrough, migration guide and document KeyVaultResolver Sync docs from upstream Azure/azure-dev main and add missing documentation for new Extension SDK helpers merged in the last 24 hours: - Add extension-sdk-reference.md: comprehensive API reference for all azdext SDK helpers including new sections for: - Process Management helpers (IsProcessRunning, GetProcessInfo, etc.) - Environment Loading helpers (LoadAzdEnvironment, ParseEnvironmentVariables) - Project Resolution helpers (GetProjectDir, FindFileUpward) - Security Validation helpers (ValidateServiceName, ValidateScriptName, etc.) - SSRF Guard (NewSSRFGuard, DefaultSSRFGuard with fluent builder) - Testing Helpers (CaptureOutput) - Key Vault Secret Resolution (KeyVaultResolver, IsSecretReference, ParseSecretReference, KeyVaultResolveError) -- from PR #7043 - Add extension-e2e-walkthrough.md: end-to-end guide for building an extension from scratch - Add extension-migration-guide.md: migration guide from pre-SDK patterns to new azdext helpers - Update extension-framework.md: sync latest content including allowed_locations for PromptLocation and quota-aware capacity resolution from PR #7397 Related PRs: #7025, #7043, #7397 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../extensions/extension-e2e-walkthrough.md | 477 ++++++++ .../docs/extensions/extension-framework.md | 238 +++- .../extensions/extension-migration-guide.md | 554 +++++++++ .../extensions/extension-sdk-reference.md | 1038 +++++++++++++++++ 4 files changed, 2294 insertions(+), 13 deletions(-) create mode 100644 cli/azd/docs/extensions/extension-e2e-walkthrough.md create mode 100644 cli/azd/docs/extensions/extension-migration-guide.md create mode 100644 cli/azd/docs/extensions/extension-sdk-reference.md diff --git a/cli/azd/docs/extensions/extension-e2e-walkthrough.md b/cli/azd/docs/extensions/extension-e2e-walkthrough.md new file mode 100644 index 00000000000..79c1ead73cc --- /dev/null +++ b/cli/azd/docs/extensions/extension-e2e-walkthrough.md @@ -0,0 +1,477 @@ +# 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](https://github.com/Azure/azure-dev/pull/6856)) +> - The `microsoft.azd.extensions` developer extension installed (`azd extension install microsoft.azd.extensions`) + +--- + +## Table of Contents + +- [Step 1: Scaffold the Extension](#step-1-scaffold-the-extension) +- [Step 2: Define the Root Command](#step-2-define-the-root-command) +- [Step 3: Add a Custom Command](#step-3-add-a-custom-command) +- [Step 4: Build an MCP Server with Tools](#step-4-build-an-mcp-server-with-tools) +- [Step 5: Register Lifecycle Event Handlers](#step-5-register-lifecycle-event-handlers) +- [Step 6: Wire It All Together](#step-6-wire-it-all-together) +- [Step 7: Build, Install, and Test](#step-7-build-install-and-test) +- [Project Structure Summary](#project-structure-summary) +- [What You Have Built](#what-you-have-built) + +--- + +## Step 1: Scaffold the Extension + +```bash +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 +# 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: + +```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()) +} +``` + +Create `internal/cmd/root.go`: + +```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`: + +```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`: + +```go +package cmd + +import ( + "context" + "fmt" + "os" + + "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 + mcpServer := 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 + 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", setTagHandler, azdext.MCPToolOptions{ + Description: "Set a tag on all resources in a resource group", + }, + 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"), + ), + ). + 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 +} + +// setTagHandler demonstrates security policy usage and error handling. +func setTagHandler(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 + } + + logger := azdext.NewLogger("mcp.set_tag") + logger.Info("setting tag", "resourceGroup", rg, "key", key, "value", value) + + // ... Azure SDK call to set tag ... + + 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 security policy. +- `ToolArgs.RequireString` / `OptionalString` for typed argument access. +- `MCPTextResult`, `MCPJSONResult`, `MCPErrorResult` for response construction. +- `DefaultMCPSecurityPolicy` for SSRF protection. + +--- + +## Step 5: Register Lifecycle Event Handlers + +Create `internal/cmd/listen.go`: + +```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`: + +```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 + +```bash +# 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 + +- [Extension SDK Reference](./extension-sdk-reference.md) — Full API reference for all helpers. +- [Extension Migration Guide](./extension-migration-guide.md) — Migrate existing extensions from legacy patterns. +- [Extension Framework](./extension-framework.md) — General framework documentation. +- [Extension Framework Services](./extension-framework-services.md) — gRPC service reference. +- [Extension Style Guide](./extensions-style-guide.md) — Design guidelines. diff --git a/cli/azd/docs/extensions/extension-framework.md b/cli/azd/docs/extensions/extension-framework.md index 9b8369e7eb9..7b4ce2d7a1b 100644 --- a/cli/azd/docs/extensions/extension-framework.md +++ b/cli/azd/docs/extensions/extension-framework.md @@ -21,12 +21,22 @@ Table of Contents - [Service Target Service](#service-target-service) - [Compose Service](#compose-service) - [Workflow Service](#workflow-service) + - [Copilot Service](#copilot-service) + +### Related Guides + +| Guide | Description | +|-------|-------------| +| [Extension SDK Reference](./extension-sdk-reference.md) | Complete API reference for `azdext` SDK helpers (command scaffolding, MCP builder, security policy, service-target base). | +| [Extension Migration Guide](./extension-migration-guide.md) | Before/after cookbook for migrating from pre-#6856 patterns to SDK helpers. | +| [Extension End-to-End Walkthrough](./extension-e2e-walkthrough.md) | Build a complete extension from scratch with root command, MCP server, lifecycle events, and security. | +| [Extension Framework Services](./extension-framework-services.md) | Custom language/framework support via `FrameworkServiceProvider`. | +| [Extension Style Guide](./extensions-style-guide.md) | Design guidelines for command integration, flags, and discoverability. | ## Getting Started -`azd` extensions are currently an alpha feature within `azd`. +`azd` extensions are currently a beta feature (Public Preview) within `azd`. -- Initially official extensions will start shipping at //BUILD 2025. - Official extensions must be developed in a fork of the [azure/azure-dev](https://github.com/azure/azure-dev) github repo. - Extension binaries are shipped as Github releases to the same repo through our official pipelines. @@ -474,18 +484,20 @@ The build process automatically creates binaries for multiple platforms and arch `azd` uses OpenTelemetry and W3C Trace Context for distributed tracing. `azd` sets `TRACEPARENT` in the environment when it launches the extension process. -Use `azdext.NewContext()` to hydrate the root context with trace context: +The recommended approach is to use `azdext.Run`, which automatically creates a trace-aware context, injects the access token, reports structured errors, and handles `os.Exit`: ```go func main() { - ctx := azdext.NewContext() - rootCmd := cmd.NewRootCommand() - if err := rootCmd.ExecuteContext(ctx); err != nil { - // Handle error - } + azdext.Run(cmd.NewRootCommand()) } ``` +For lifecycle-listener extensions, `azdext.NewListenCommand` sets up trace context and access token automatically within its handler. + +> **Note:** `azdext.NewContext()` is deprecated. Use `azdext.Run` for custom-command extensions +> or `azdext.NewListenCommand`/`azdext.NewExtensionRootCommand` for lifecycle listeners. +> `NewContext` remains available for backward compatibility but new extensions should not use it. + To correlate Azure SDK calls with the parent trace, add the correlation policy to your client options: ```go @@ -759,7 +771,7 @@ Common issues you might encounter when developing and publishing extensions: ### Capabilities -#### Current Capabilities (October 2025) +#### Current Capabilities The following lists the current capabilities available to `azd` extensions: @@ -1087,17 +1099,26 @@ examples: The following is an example of an [extension manifest](../extensions/microsoft.azd.demo/extension.yaml). ```yaml -# yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/refs/heads/main/cli/azd/extensions/registry.schema.json +# yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/refs/heads/main/cli/azd/extensions/extension.schema.json id: microsoft.azd.demo namespace: demo displayName: Demo Extension description: This extension provides examples of the azd extension framework. usage: azd demo [options] -version: 0.1.0 +version: 0.6.0 +language: go capabilities: - custom-commands - lifecycle-events + - mcp-server + - service-target-provider + - framework-service-provider + - metadata +providers: + - name: demo + type: service-target + description: Deploys application components to demo examples: - name: context description: Displays the current `azd` project & environment context. @@ -1138,11 +1159,12 @@ func main() { } ``` -Alternatively, you can use `azdext.ReportError` directly for lower-level control: +Alternatively, you can use `azdext.ReportError` directly for lower-level control +(note: `NewContext` is deprecated — prefer `Run` for new extensions): ```go func main() { - ctx := azdext.NewContext() + ctx := azdext.NewContext() // Deprecated: prefer azdext.Run ctx = azdext.WithAccessToken(ctx) rootCmd := cmd.NewRootCommand() @@ -1358,6 +1380,7 @@ The following are a list of available gRPC services for extension developer to i - [Service Target Service](#service-target-service) - [Compose Service](#compose-service) - [Workflow Service](#workflow-service) +- [Copilot Service](#copilot-service) --- @@ -2793,3 +2816,192 @@ func getSubscriptionDetails(ctx context.Context, azdClient *azdext.AzdClient, su - Handle multi-tenant scenarios where users access subscriptions through different tenants - Validate subscription access before performing operations - Set up proper authentication context for Azure SDK calls + +--- + +### Copilot Service + +This service provides Copilot agent capabilities to extensions. Sessions are created lazily on the first `SendMessage` call and can be reused across multiple calls. Sessions run in headless/autopilot mode by default when invoked via gRPC, suppressing all console output. + +> See [copilot.proto](../grpc/proto/copilot.proto) for more details. + +#### Initialize + +Starts the Copilot client, verifies authentication, and resolves model/reasoning configuration. Call before `SendMessage` to warm up the client and validate settings. This method is idempotent. + +- **Request:** _InitializeCopilotRequest_ + - Contains: + - `model` (string): Model to configure (empty = use existing/default) + - `reasoning_effort` (string): Reasoning effort level (`low`, `medium`, `high`) +- **Response:** _InitializeCopilotResponse_ + - Contains: + - `model` (string): Resolved model name + - `reasoning_effort` (string): Resolved reasoning effort level + - `is_first_run` (bool): True if this was the first configuration + +#### ListSessions + +Returns available Copilot sessions for a working directory. + +- **Request:** _ListCopilotSessionsRequest_ + - Contains: + - `working_directory` (string): Directory to list sessions for (empty = current working directory) +- **Response:** _ListCopilotSessionsResponse_ + - Contains a list of **CopilotSessionMetadata**: + - `session_id` (string): Unique session identifier + - `modified_time` (string): Last modified time (RFC 3339 format) + - `summary` (string): Optional session summary + +#### SendMessage + +Sends a prompt to the Copilot agent. On the first call, a new session is created using the provided configuration. If `session_id` is set, that existing session is resumed instead. Subsequent calls with the returned `session_id` reuse the same session without re-bootstrapping. + +- **Request:** _SendCopilotMessageRequest_ + - Contains: + - `prompt` (string): The prompt/message to send + - `session_id` (string, optional): Session to reuse or resume + - `model` (string, optional): Model override (first call or resume) + - `reasoning_effort` (string, optional): Reasoning effort (`low`, `medium`, `high`) + - `system_message` (string, optional): Custom system message appended to the default + - `mode` (string, optional): Agent mode (`autopilot`, `interactive`, `plan`) + - `debug` (bool, optional): Enable debug logging + - `headless` (bool, optional): Set to true for headless mode (suppresses console output) +- **Response:** _SendCopilotMessageResponse_ + - Contains: + - `session_id` (string): Session ID — use in subsequent calls to reuse the session + - `usage` (_CopilotUsageMetrics_): Usage metrics for this message turn + - `file_changes` (repeated _CopilotFileChange_): Files changed during this message turn + +#### GetUsageMetrics + +Returns cumulative usage metrics cached for a session. + +- **Request:** _GetCopilotUsageMetricsRequest_ + - Contains: + - `session_id` (string): Session to get metrics for +- **Response:** _GetCopilotUsageMetricsResponse_ + - Contains **CopilotUsageMetrics**: + - `model` (string): Model used + - `input_tokens` (double): Total input tokens consumed + - `output_tokens` (double): Total output tokens consumed + - `total_tokens` (double): Sum of input + output tokens + - `billing_rate` (double): Per-request cost multiplier (e.g., 1.0x, 2.0x) + - `premium_requests` (double): Number of premium requests used + - `duration_ms` (double): Total API duration in milliseconds + +#### GetFileChanges + +Returns files created, modified, or deleted during a session. + +- **Request:** _GetCopilotFileChangesRequest_ + - Contains: + - `session_id` (string): Session to get file changes for +- **Response:** _GetCopilotFileChangesResponse_ + - Contains a list of **CopilotFileChange**: + - `path` (string): File path (relative to working directory) + - `change_type` (_CopilotFileChangeType_): One of `CREATED`, `MODIFIED`, `DELETED` + +#### StopSession + +Stops and cleans up a Copilot agent session. + +- **Request:** _StopCopilotSessionRequest_ + - Contains: + - `session_id` (string): Session to stop +- **Response:** _EmptyResponse_ + +#### GetMessages + +Returns the session event log from the Copilot SDK. Each event contains a type, timestamp, and dynamic data as a `google.protobuf.Struct`. + +- **Request:** _GetCopilotMessagesRequest_ + - Contains: + - `session_id` (string): Session to get messages for +- **Response:** _GetCopilotMessagesResponse_ + - Contains a list of **CopilotSessionEvent**: + - `type` (string): Event type (e.g., `assistant.message`, `tool.execution_complete`) + - `timestamp` (string): ISO 8601 timestamp + - `data` (google.protobuf.Struct): Full event data as a dynamic struct + +**Example Usage (Go):** + +```go +ctx := azdext.WithAccessToken(cmd.Context()) +azdClient, err := azdext.NewAzdClient() +if err != nil { + return fmt.Errorf("failed to create azd client: %w", err) +} +defer azdClient.Close() + +copilot := azdClient.Copilot() + +// Optional: warm up the client and resolve configuration +initResp, err := copilot.Initialize(ctx, &azdext.InitializeCopilotRequest{ + Model: "gpt-4o", + ReasoningEffort: "medium", +}) +if err != nil { + return fmt.Errorf("failed to initialize copilot: %w", err) +} +fmt.Printf("Model: %s, Reasoning: %s\n", initResp.Model, initResp.ReasoningEffort) + +// Send the first message — creates a new session +sendResp, err := copilot.SendMessage(ctx, &azdext.SendCopilotMessageRequest{ + Prompt: "Add a health check endpoint to the API", + Mode: "autopilot", + Headless: true, + SystemMessage: "You are an expert Go developer.", +}) +if err != nil { + return fmt.Errorf("failed to send message: %w", err) +} + +// Capture the session ID for subsequent calls +sessionID := sendResp.SessionId + +// Send a follow-up message in the same session +followUp, err := copilot.SendMessage(ctx, &azdext.SendCopilotMessageRequest{ + Prompt: "Now add tests for the health check endpoint", + SessionId: sessionID, +}) +if err != nil { + return fmt.Errorf("failed to send follow-up: %w", err) +} +fmt.Printf("Turn usage: %.0f tokens\n", followUp.Usage.TotalTokens) + +// Retrieve cumulative metrics +metricsResp, err := copilot.GetUsageMetrics(ctx, &azdext.GetCopilotUsageMetricsRequest{ + SessionId: sessionID, +}) +if err != nil { + return fmt.Errorf("failed to get metrics: %w", err) +} +fmt.Printf("Total tokens: %.0f, Premium requests: %.0f\n", + metricsResp.Usage.TotalTokens, metricsResp.Usage.PremiumRequests) + +// Retrieve file changes +changesResp, err := copilot.GetFileChanges(ctx, &azdext.GetCopilotFileChangesRequest{ + SessionId: sessionID, +}) +if err != nil { + return fmt.Errorf("failed to get file changes: %w", err) +} +for _, change := range changesResp.FileChanges { + fmt.Printf(" %s: %s\n", change.ChangeType, change.Path) +} + +// Clean up the session +_, err = copilot.StopSession(ctx, &azdext.StopCopilotSessionRequest{ + SessionId: sessionID, +}) +if err != nil { + return fmt.Errorf("failed to stop session: %w", err) +} +``` + +**Use Cases:** + +- Automate code generation or refactoring tasks within an extension workflow +- Build interactive AI-assisted tools powered by Copilot +- Track token consumption and file modifications during AI-driven operations +- Resume previous sessions for iterative, multi-step tasks diff --git a/cli/azd/docs/extensions/extension-migration-guide.md b/cli/azd/docs/extensions/extension-migration-guide.md new file mode 100644 index 00000000000..f313d690d5a --- /dev/null +++ b/cli/azd/docs/extensions/extension-migration-guide.md @@ -0,0 +1,554 @@ +# Extension Migration Guide + +This guide helps extension authors migrate from pre-[#6856](https://github.com/Azure/azure-dev/pull/6856) patterns to the new `azdext` SDK helpers. Each section shows a **before** (legacy) and **after** (recommended) pattern with a brief explanation of what changed and why. + +> **Applies to:** Extensions targeting azd ≥ 1.23.7 with `azdext` SDK helpers. + +--- + +## Table of Contents + +- [M1: Entry Point — NewContext → Run](#m1-entry-point--newcontext--run) +- [M2: Root Command — Manual Flags → NewExtensionRootCommand](#m2-root-command--manual-flags--newextensionrootcommand) +- [M3: Listen Command — Manual Host Setup → NewListenCommand](#m3-listen-command--manual-host-setup--newlistencommand) +- [M4: MCP Server — Manual Construction → MCPServerBuilder](#m4-mcp-server--manual-construction--mcpserverbuilder) +- [M5: MCP Tool Arguments — Raw Map Access → ToolArgs](#m5-mcp-tool-arguments--raw-map-access--toolargs) +- [M6: MCP Responses — Manual Result Construction → Result Helpers](#m6-mcp-responses--manual-result-construction--result-helpers) +- [M7: SSRF / Path Validation — Custom Checks → MCPSecurityPolicy](#m7-ssrf--path-validation--custom-checks--mcpsecuritypolicy) +- [M8: Service Target — Full Interface → BaseServiceTargetProvider](#m8-service-target--full-interface--baseservicetargetprovider) +- [M9: Metadata Command — Hand-Rolled → NewMetadataCommand](#m9-metadata-command--hand-rolled--newmetadatacommand) +- [M10: Version Command — Custom → NewVersionCommand](#m10-version-command--custom--newversioncommand) +- [Compatibility Notes](#compatibility-notes) +- [Step-by-Step Migration Checklist](#step-by-step-migration-checklist) + +--- + +## M1: Entry Point — NewContext → Run + +### Before (legacy) + +```go +func main() { + ctx, err := azdext.NewContext() + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + rootCmd := cmd.NewRootCommand() + rootCmd.SetContext(ctx) + if err := rootCmd.Execute(); err != nil { + os.Exit(1) + } +} +``` + +**Problems:** Manual error display, no structured error reporting to azd, no +`FORCE_COLOR` handling, exit codes not standardized. + +### After (recommended) + +```go +func main() { + azdext.Run(cmd.NewRootCommand()) +} +``` + +**What changed:** `Run` handles context creation, `FORCE_COLOR`, trace +propagation, access-token injection, structured error reporting via gRPC +`ReportError`, and `os.Exit`. One line replaces ~15 lines of boilerplate. + +> **Note:** `NewContext()` is deprecated but remains available for backward +> compatibility. New extensions should not use it. + +--- + +## M2: Root Command — Manual Flags → NewExtensionRootCommand + +### Before (legacy) + +```go +var ( + debug bool + noPrompt bool + cwd string + environment string + output string +) + +func NewRootCommand() *cobra.Command { + rootCmd := &cobra.Command{ + Use: "my-extension", + Short: "My extension", + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + // Manual trace context extraction + traceparent := os.Getenv("TRACEPARENT") + tracestate := os.Getenv("TRACESTATE") + if traceparent != "" { + ctx := propagation.TraceContext{}.Extract(cmd.Context(), + propagation.MapCarrier{ + "traceparent": traceparent, + "tracestate": tracestate, + }) + cmd.SetContext(ctx) + } + // Manual access token + cmd.SetContext(azdext.WithAccessToken(cmd.Context())) + return nil + }, + } + + rootCmd.PersistentFlags().BoolVar(&debug, "debug", false, "Enable debug logging") + rootCmd.PersistentFlags().BoolVar(&noPrompt, "no-prompt", false, "Disable prompts") + rootCmd.PersistentFlags().StringVar(&cwd, "cwd", "", "Working directory") + rootCmd.PersistentFlags().StringVarP(&environment, "environment", "e", "", "Environment") + rootCmd.PersistentFlags().StringVar(&output, "output", "", "Output format") + + return rootCmd +} +``` + +**Problems:** 30-50 lines of identical boilerplate in every extension. Flag names +and trace-context extraction can drift from what azd expects. + +### After (recommended) + +```go +func NewRootCommand() *cobra.Command { + rootCmd, extCtx := azdext.NewExtensionRootCommand(azdext.ExtensionCommandOptions{ + Name: "my-extension", + Version: "1.0.0", + Short: "My extension", + }) + + // Use extCtx.Debug, extCtx.Cwd, etc. in subcommands + rootCmd.AddCommand(newServeCommand(extCtx)) + + return rootCmd +} +``` + +**What changed:** `NewExtensionRootCommand` registers all standard flags, reads +`AZD_*` env vars, and sets up trace context + access token in +`PersistentPreRunE`. The `ExtensionContext` struct provides typed access to the +parsed values. + +--- + +## M3: Listen Command — Manual Host Setup → NewListenCommand + +### Before (legacy) + +```go +func newListenCommand() *cobra.Command { + return &cobra.Command{ + Use: "listen", + Short: "Starts the extension and listens for events.", + RunE: func(cmd *cobra.Command, args []string) error { + ctx := azdext.WithAccessToken(cmd.Context()) + azdClient, err := azdext.NewAzdClient() + if err != nil { + return fmt.Errorf("failed to create azd client: %w", err) + } + defer azdClient.Close() + + host := azdext.NewExtensionHost(azdClient). + WithServiceTarget("myhost", func() azdext.ServiceTargetProvider { + return &MyProvider{client: azdClient} + }) + + return host.Run(ctx) + }, + } +} +``` + +### After (recommended) + +```go +rootCmd.AddCommand(azdext.NewListenCommand(func(host *azdext.ExtensionHost) { + host.WithServiceTarget("myhost", func() azdext.ServiceTargetProvider { + return &MyProvider{client: host.Client()} + }) +})) +``` + +**What changed:** `NewListenCommand` handles client creation, context injection, +and `defer Close()` internally. The `configure` callback receives the fully +initialized host. + +--- + +## M4: MCP Server — Manual Construction → MCPServerBuilder + +### Before (legacy) + +```go +mcpServer := server.NewMCPServer("my-mcp", "1.0.0") + +// Manual rate limiter setup +limiter := rate.NewLimiter(rate.Limit(2.0), 10) + +// Manual tool registration with raw handler +mcpServer.AddTool( + mcp.NewTool("list_items", + mcp.WithDescription("List items"), + mcp.WithString("query", mcp.Required(), mcp.Description("Search query")), + ), + func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + // Manual rate limiting check + if !limiter.Allow() { + return &mcp.CallToolResult{ + IsError: true, + Content: []mcp.Content{{Type: "text", Text: ptr("rate limited")}}, + }, nil + } + // Manual arg extraction + args := req.Params.Arguments + query, ok := args["query"].(string) + if !ok { + return nil, fmt.Errorf("missing required argument: query") + } + // ... handler logic ... + return nil, nil + }, +) +``` + +### After (recommended) + +```go +mcpServer := azdext.NewMCPServerBuilder("my-mcp", "1.0.0"). + WithRateLimit(10, 2.0). + WithSecurityPolicy(azdext.DefaultMCPSecurityPolicy()). + AddTool("list_items", listItemsHandler, azdext.MCPToolOptions{ + Description: "List items", + }, + mcp.WithString("query", mcp.Required(), mcp.Description("Search query")), + ). + Build() + +func listItemsHandler(ctx context.Context, args azdext.ToolArgs) (*mcp.CallToolResult, error) { + query, err := args.RequireString("query") + if err != nil { + return azdext.MCPErrorResult("missing argument: %v", err), nil + } + // ... handler logic ... + return azdext.MCPJSONResult(results), nil +} +``` + +**What changed:** The builder handles rate-limiter wiring, security policy +attachment, and argument parsing. Tool handlers receive `ToolArgs` instead of +raw `CallToolRequest`. + +--- + +## M5: MCP Tool Arguments — Raw Map Access → ToolArgs + +### Before (legacy) + +```go +func handler(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := req.Params.Arguments + name, ok := args["name"].(string) + if !ok { + return nil, fmt.Errorf("missing name") + } + // JSON numbers are float64, manual conversion needed + countRaw, ok := args["count"] + count := 10 // default + if ok { + if f, ok := countRaw.(float64); ok { + count = int(f) + } + } + verbose := false + if v, ok := args["verbose"].(bool); ok { + verbose = v + } + // ... +} +``` + +### After (recommended) + +```go +func handler(ctx context.Context, args azdext.ToolArgs) (*mcp.CallToolResult, error) { + name, err := args.RequireString("name") + if err != nil { + return azdext.MCPErrorResult("%v", err), nil + } + count := args.OptionalInt("count", 10) + verbose := args.OptionalBool("verbose", false) + // ... +} +``` + +**What changed:** `ToolArgs` handles JSON `float64` → `int` conversion, type +checking, and defaults in a single method call. `Require*` methods return +errors; `Optional*` methods return defaults. + +--- + +## M6: MCP Responses — Manual Result Construction → Result Helpers + +### Before (legacy) + +```go +// Text result +textPtr := func(s string) *string { return &s } +result := &mcp.CallToolResult{ + Content: []mcp.Content{{Type: "text", Text: textPtr("Success: 5 items found")}}, +} + +// JSON result +jsonBytes, err := json.Marshal(data) +if err != nil { + return nil, err +} +result := &mcp.CallToolResult{ + Content: []mcp.Content{{Type: "text", Text: textPtr(string(jsonBytes))}}, +} + +// Error result +result := &mcp.CallToolResult{ + IsError: true, + Content: []mcp.Content{{Type: "text", Text: textPtr("failed: " + err.Error())}}, +} +``` + +### After (recommended) + +```go +return azdext.MCPTextResult("Success: %d items found", 5), nil +return azdext.MCPJSONResult(data), nil +return azdext.MCPErrorResult("failed: %v", err), nil +``` + +**What changed:** One-liners replace 3-5 lines each. `MCPJSONResult` handles +marshal errors internally (returns an error result if marshaling fails). + +--- + +## M7: SSRF / Path Validation — Custom Checks → MCPSecurityPolicy + +### Before (legacy) + +```go +func validateURL(rawURL string) error { + u, err := url.Parse(rawURL) + if err != nil { + return err + } + // Check for metadata endpoints + if u.Hostname() == "169.254.169.254" { + return fmt.Errorf("metadata endpoint blocked") + } + // Check for private IPs - incomplete, easy to miss ranges + ip := net.ParseIP(u.Hostname()) + if ip != nil && ip.IsPrivate() { + return fmt.Errorf("private network blocked") + } + // Missing: CGNAT, IPv6 transition, cloud metadata variants, etc. + return nil +} +``` + +### After (recommended) + +```go +policy := azdext.DefaultMCPSecurityPolicy() + +// Or build a custom policy: +policy := azdext.NewMCPSecurityPolicy(). + BlockMetadataEndpoints(). + BlockPrivateNetworks(). + RequireHTTPS(). + ValidatePathsWithinBase(projectDir) + +if err := policy.CheckURL(userURL); err != nil { + return azdext.MCPErrorResult("blocked: %v", err), nil +} +if err := policy.CheckPath(userPath); err != nil { + return azdext.MCPErrorResult("blocked: %v", err), nil +} +``` + +**What changed:** `MCPSecurityPolicy` covers cloud metadata (AWS, GCP, Azure +IMDS), RFC 1918, CGNAT (RFC 6598), IPv6 transition mechanisms (6to4, Teredo, +NAT64), symlink resolution, and sensitive header redaction — areas that manual +checks commonly miss. + +--- + +## M8: Service Target — Full Interface → BaseServiceTargetProvider + +### Before (legacy) + +```go +type MyProvider struct { + client *azdext.AzdClient +} + +// Must implement ALL 6 methods even if unused +func (p *MyProvider) Initialize(ctx context.Context, sc *azdext.ServiceConfig) error { + return nil +} +func (p *MyProvider) Endpoints(ctx context.Context, sc *azdext.ServiceConfig, + tr *azdext.TargetResource) ([]string, error) { + return nil, nil +} +func (p *MyProvider) GetTargetResource(ctx context.Context, subId string, + sc *azdext.ServiceConfig, defaultResolver func() (*azdext.TargetResource, error), +) (*azdext.TargetResource, error) { + return nil, nil +} +func (p *MyProvider) Package(ctx context.Context, sc *azdext.ServiceConfig, + sctx *azdext.ServiceContext, progress azdext.ProgressReporter, +) (*azdext.ServicePackageResult, error) { + return nil, nil +} +func (p *MyProvider) Publish(ctx context.Context, sc *azdext.ServiceConfig, + sctx *azdext.ServiceContext, tr *azdext.TargetResource, + opts *azdext.PublishOptions, progress azdext.ProgressReporter, +) (*azdext.ServicePublishResult, error) { + return nil, nil +} +func (p *MyProvider) Deploy(ctx context.Context, sc *azdext.ServiceConfig, + sctx *azdext.ServiceContext, tr *azdext.TargetResource, progress azdext.ProgressReporter, +) (*azdext.ServiceDeployResult, error) { + // Only method actually needed + // ... deploy logic ... + return &azdext.ServiceDeployResult{}, nil +} +``` + +### After (recommended) + +```go +type MyProvider struct { + azdext.BaseServiceTargetProvider // no-op defaults for all methods + client *azdext.AzdClient +} + +// Override only what you need +func (p *MyProvider) Deploy(ctx context.Context, sc *azdext.ServiceConfig, + sctx *azdext.ServiceContext, tr *azdext.TargetResource, progress azdext.ProgressReporter, +) (*azdext.ServiceDeployResult, error) { + // ... deploy logic ... + return &azdext.ServiceDeployResult{}, nil +} +``` + +**What changed:** Embed `BaseServiceTargetProvider` and override only the +methods you need. Eliminates ~40 lines of no-op stubs per provider. + +--- + +## M9: Metadata Command — Hand-Rolled → NewMetadataCommand + +### Before (legacy) + +```go +func newMetadataCommand() *cobra.Command { + return &cobra.Command{ + Use: "metadata", + Short: "Generate extension metadata", + Hidden: true, + RunE: func(cmd *cobra.Command, args []string) error { + rootCmd := newRootCommand() + metadata := azdext.GenerateExtensionMetadata("1.0", "my.extension", rootCmd) + jsonBytes, _ := json.MarshalIndent(metadata, "", " ") + fmt.Println(string(jsonBytes)) + return nil + }, + } +} +``` + +### After (recommended) + +```go +rootCmd.AddCommand(azdext.NewMetadataCommand("1.0", "my.extension", newRootCommand)) +``` + +--- + +## M10: Version Command — Custom → NewVersionCommand + +### Before (legacy) + +```go +func newVersionCommand() *cobra.Command { + return &cobra.Command{ + Use: "version", + Short: "Print version", + RunE: func(cmd *cobra.Command, args []string) error { + fmt.Printf("my.extension version %s\n", Version) + return nil + }, + } +} +``` + +### After (recommended) + +```go +rootCmd.AddCommand(azdext.NewVersionCommand("my.extension", "1.0.0", &extCtx.OutputFormat)) +``` + +Supports `--output json` automatically when the output-format pointer is +provided. + +--- + +## Compatibility Notes + +1. **`azdext.NewContext()` is deprecated** — it remains functional but should + not be used in new code. Use `azdext.Run` + `NewExtensionRootCommand` instead. + +2. **All migrations are additive** — existing extensions continue to compile + and run without changes. You can migrate incrementally. + +3. **Minimum azd version** — The SDK helpers ship in azd ≥ 1.23.7. If your + extension must support older azd versions, guard imports behind a build tag + or version check. + +4. **`mcp-go` dependency** — MCP helpers wrap `mark3labs/mcp-go`. Your + extension's `go.mod` must include this dependency. Run `go mod tidy` after + migration. + +--- + +## Step-by-Step Migration Checklist + +Use this checklist to migrate an existing extension: + +- [ ] **Replace entry point:** Change `main()` to use `azdext.Run(rootCmd)`. +- [ ] **Replace root command:** Use `NewExtensionRootCommand` instead of manual + flag registration and trace-context extraction. +- [ ] **Replace listen command:** Use `NewListenCommand(configure)` instead of + manual host setup. +- [ ] **Replace MCP server construction:** Use `MCPServerBuilder` with + `AddTool`, `WithRateLimit`, `WithSecurityPolicy`. +- [ ] **Replace argument parsing:** Switch tool handlers to `MCPToolHandler` + signature and use `ToolArgs` methods. +- [ ] **Replace result construction:** Use `MCPTextResult`, `MCPJSONResult`, + `MCPErrorResult`. +- [ ] **Add security policy:** Use `DefaultMCPSecurityPolicy()` or build a + custom policy. +- [ ] **Embed BaseServiceTargetProvider:** Remove no-op method stubs; embed + the base struct. +- [ ] **Replace metadata/version commands:** Use `NewMetadataCommand` and + `NewVersionCommand`. +- [ ] **Run `go mod tidy`** to pick up new dependencies. +- [ ] **Test:** Build and run the extension against azd ≥ 1.23.7. + +--- + +## See Also + +- [Extension SDK Reference](./extension-sdk-reference.md) — Full API reference. +- [Extension End-to-End Walkthrough](./extension-e2e-walkthrough.md) — Build a complete extension from scratch. +- [Extension Framework](./extension-framework.md) — General framework documentation. diff --git a/cli/azd/docs/extensions/extension-sdk-reference.md b/cli/azd/docs/extensions/extension-sdk-reference.md new file mode 100644 index 00000000000..0ddbe998f6f --- /dev/null +++ b/cli/azd/docs/extensions/extension-sdk-reference.md @@ -0,0 +1,1038 @@ +# Extension SDK Reference + +This document is the API reference for the `azdext` SDK helpers. These helpers eliminate boilerplate that every azd extension must otherwise implement manually, covering command scaffolding, MCP server construction, typed argument parsing, security policy, Key Vault secret resolution, and service-target base implementations. + +> **Package import:** `"github.com/azure/azure-dev/cli/azd/pkg/azdext"` + +--- + +## Table of Contents + +- [Entry Point & Lifecycle](#entry-point--lifecycle) + - [Run](#run) + - [RunOption / WithPreExecute](#runoption--withpreexecute) +- [Command Scaffolding](#command-scaffolding) + - [NewExtensionRootCommand](#newextensionrootcommand) + - [ExtensionCommandOptions](#extensioncommandoptions) + - [ExtensionContext](#extensioncontext) + - [NewListenCommand](#newlistencommand) + - [NewMetadataCommand](#newmetadatacommand) + - [NewVersionCommand](#newversioncommand) +- [MCP Server Builder](#mcp-server-builder) + - [NewMCPServerBuilder](#newmcpserverbuilder) + - [MCPServerBuilder Methods](#mcpserverbuilder-methods) + - [MCPToolHandler](#mcptoolhandler) + - [MCPToolOptions](#mcptooloptions) +- [Typed Argument Parsing](#typed-argument-parsing) + - [ToolArgs](#toolargs) + - [ParseToolArgs](#parsetoolargs) +- [MCP Result Helpers](#mcp-result-helpers) + - [MCPTextResult](#mcptextresult) + - [MCPJSONResult](#mcpjsonresult) + - [MCPErrorResult](#mcperrorresult) +- [MCP Security Policy](#mcp-security-policy) + - [NewMCPSecurityPolicy](#newmcpsecuritypolicy) + - [DefaultMCPSecurityPolicy](#defaultmcpsecuritypolicy) + - [MCPSecurityPolicy Methods](#mcpsecuritypolicy-methods) +- [Service Target Providers](#service-target-providers) + - [ServiceTargetProvider Interface](#servicetargetprovider-interface) + - [BaseServiceTargetProvider](#baseservicetargetprovider) +- [Extension Host](#extension-host) + - [NewExtensionHost](#newextensionhost) + - [ExtensionHost Methods](#extensionhost-methods) +- [Client & Utilities](#client--utilities) + - [AzdClient](#azdclient) + - [ConfigHelper](#confighelper) + - [TokenProvider](#tokenprovider) + - [Logger](#logger) + - [Output](#output) + - [Runtime Utilities](#runtime-utilities) + - [Shell Helpers](#shell-helpers) + - [Tool Discovery Helpers](#tool-discovery-helpers) + - [Interactive/TUI Helpers](#interactivetui-helpers) + - [Atomic File Helpers](#atomic-file-helpers) + - [Process Management Helpers](#process-management-helpers) + - [Environment Loading Helpers](#environment-loading-helpers) + - [Project Resolution Helpers](#project-resolution-helpers) + - [Security Validation Helpers](#security-validation-helpers) + - [SSRF Guard](#ssrf-guard) + - [Testing Helpers](#testing-helpers) +- [Key Vault Secret Resolution](#key-vault-secret-resolution) + - [KeyVaultResolver](#keyvaultresolver) + - [NewKeyVaultResolver](#newkeyvaultresolver) + - [KeyVaultResolver Methods](#keyvaultresolver-methods) + - [SecretReference](#secretreference) + - [IsSecretReference / ParseSecretReference](#issecretreference--parsesecretreference) + - [KeyVaultResolveError](#keyvaultresolveerror) +- [Error Handling](#error-handling) + - [LocalError](#localerror) + - [ServiceError](#serviceerror) + - [LocalErrorCategory](#localerrorcategory) + +--- + +## Entry Point & Lifecycle + +### Run + +```go +func Run(rootCmd *cobra.Command, opts ...RunOption) +``` + +`Run` is the **recommended entry point** for all azd extensions. It handles the +full lifecycle that every extension needs: + +1. Reads `FORCE_COLOR` environment variable and configures `color.NoColor`. +2. Silences cobra's built-in error output (extensions control error display). +3. Creates a context with OpenTelemetry trace propagation from `TRACEPARENT`/`TRACESTATE`. +4. Injects the gRPC access token via `WithAccessToken`. +5. Executes the cobra command tree. +6. On failure, reports the error to azd via gRPC `ReportError` for structured telemetry. +7. Displays the error and any suggestion text to stderr. +8. Calls `os.Exit(1)` on failure. + +**Usage:** + +```go +func main() { + rootCmd := cmd.NewRootCommand() + azdext.Run(rootCmd) +} +``` + +### RunOption / WithPreExecute + +```go +type RunOption func(*runConfig) + +func WithPreExecute(fn func(ctx context.Context, cmd *cobra.Command) error) RunOption +``` + +`WithPreExecute` registers a hook that runs **after** context creation but +**before** command execution. If the hook returns a non-nil error, `Run` prints +it and exits. This is useful for extensions that need special setup such as +dual-mode host detection or working-directory changes. + +**Usage:** + +```go +func main() { + rootCmd := cmd.NewRootCommand() + azdext.Run(rootCmd, azdext.WithPreExecute(func(ctx context.Context, cmd *cobra.Command) error { + // Validate prerequisites + if _, err := exec.LookPath("docker"); err != nil { + return fmt.Errorf("docker is required: %w", err) + } + return nil + })) +} +``` + +--- + +## Command Scaffolding + +### NewExtensionRootCommand + +```go +func NewExtensionRootCommand(opts ExtensionCommandOptions) (*cobra.Command, *ExtensionContext) +``` + +Creates a root `cobra.Command` pre-configured for azd extensions. It +automatically: + +- Registers azd's global flags (`--debug`, `--no-prompt`, `--cwd`, + `-e`/`--environment`, `--output`). +- Reads `AZD_*` environment variables set by the azd framework. +- Sets up OpenTelemetry trace context from `TRACEPARENT`/`TRACESTATE` env vars. +- Calls `WithAccessToken()` on the command context. + +The returned command has `PersistentPreRunE` configured to populate the +`ExtensionContext` before any subcommand runs. + +**Usage:** + +```go +rootCmd, extCtx := azdext.NewExtensionRootCommand(azdext.ExtensionCommandOptions{ + Name: "my-extension", + Version: "1.0.0", + Short: "My custom azd extension", +}) + +// Add subcommands +rootCmd.AddCommand(newServeCommand(extCtx)) + +azdext.Run(rootCmd) +``` + +### ExtensionCommandOptions + +```go +type ExtensionCommandOptions struct { + Name string // Extension name (used as cobra Use field) + Version string // Extension version + Use string // Overrides the default Use string (defaults to Name) + Short string // Short description + Long string // Long description +} +``` + +### ExtensionContext + +```go +type ExtensionContext struct { + Debug bool // --debug flag value + NoPrompt bool // --no-prompt flag value + Cwd string // --cwd flag value + Environment string // -e/--environment flag value + OutputFormat string // --output flag value +} + +func (ec *ExtensionContext) Context() context.Context +``` + +`Context()` returns a `context.Context` with the tracing span and access token +already injected. Use this context for all downstream calls (gRPC, HTTP, +Azure SDK). + +### NewListenCommand + +```go +func NewListenCommand(configure func(host *ExtensionHost)) *cobra.Command +``` + +Creates the standard `listen` command for lifecycle-event extensions. The +`configure` callback receives an `ExtensionHost` to register service targets, +framework services, and event handlers before the host starts its gRPC listener. + +If `configure` is nil, the host runs with no custom registrations. + +**Usage:** + +```go +rootCmd.AddCommand(azdext.NewListenCommand(func(host *azdext.ExtensionHost) { + host.WithServiceTarget("myhost", func() azdext.ServiceTargetProvider { + return &MyProvider{} + }) + host.WithProjectEventHandler("preprovision", myHandler) +})) +``` + +### NewMetadataCommand + +```go +func NewMetadataCommand( + schemaVersion, extensionId string, + rootCmdProvider func() *cobra.Command, +) *cobra.Command +``` + +Creates the standard hidden `metadata` command that outputs extension command +metadata for IntelliSense/discovery. `rootCmdProvider` returns the root command +to introspect. + +### NewVersionCommand + +```go +func NewVersionCommand(extensionId, version string, outputFormat *string) *cobra.Command +``` + +Creates the standard `version` command. Pass a pointer to the output-format +string so JSON output is supported when `--output json` is used. + +--- + +## MCP Server Builder + +### NewMCPServerBuilder + +```go +func NewMCPServerBuilder(name, version string) *MCPServerBuilder +``` + +Creates a new builder for an MCP (Model Context Protocol) server. The builder +provides a fluent API to configure tools, resources, rate limiting, security +policies, and instructions. + +### MCPServerBuilder Methods + +| Method | Signature | Description | +|--------|-----------|-------------| +| `WithRateLimit` | `(burst int, refillRate float64) *MCPServerBuilder` | Configure a token-bucket rate limiter. `burst` = max concurrent requests; `refillRate` = tokens/second. | +| `WithSecurityPolicy` | `(policy *MCPSecurityPolicy) *MCPServerBuilder` | Attach a security policy for URL/path validation on tool calls. | +| `WithInstructions` | `(instructions string) *MCPServerBuilder` | Set system instructions that guide AI clients on how to use the server's tools. | +| `WithResourceCapabilities` | `(subscribe, listChanged bool) *MCPServerBuilder` | Enable resource support. | +| `WithPromptCapabilities` | `(listChanged bool) *MCPServerBuilder` | Enable prompt support. | +| `WithServerOption` | `(opt server.ServerOption) *MCPServerBuilder` | Add a raw `mcp-go` server option for capabilities not directly exposed by the builder. | +| `AddTool` | `(name string, handler MCPToolHandler, opts MCPToolOptions, params ...mcp.ToolOption) *MCPServerBuilder` | Register a tool with the server. The handler receives parsed `ToolArgs` (not raw `mcp.CallToolRequest`). | +| `AddResources` | `(resources ...server.ServerResource) *MCPServerBuilder` | Register static resources. | +| `Build` | `() *server.MCPServer` | Create the configured MCP server. | +| `SecurityPolicy` | `() *MCPSecurityPolicy` | Return the configured security policy, or `nil`. | + +**Usage:** + +```go +mcpServer := azdext.NewMCPServerBuilder("my-ext", "1.0.0"). + WithRateLimit(10, 2.0). + WithSecurityPolicy(azdext.DefaultMCPSecurityPolicy()). + WithInstructions("Use these tools to manage Azure resources."). + AddTool("list_resources", listHandler, azdext.MCPToolOptions{ + Description: "List Azure resources in a resource group", + }, + mcp.WithString("resourceGroup", mcp.Required(), mcp.Description("Resource group name")), + mcp.WithString("subscription", mcp.Description("Subscription ID")), + ). + Build() +``` + +### MCPToolHandler + +```go +type MCPToolHandler func(ctx context.Context, args ToolArgs) (*mcp.CallToolResult, error) +``` + +Handler function for MCP tools. The `args` parameter provides typed access to +tool arguments (see [ToolArgs](#toolargs)). + +### MCPToolOptions + +```go +type MCPToolOptions struct { + Description string // Human-readable tool description +} +``` + +--- + +## Typed Argument Parsing + +### ToolArgs + +Wraps parsed MCP tool arguments for typed, safe access. JSON numbers from MCP +requests arrive as `float64`; the `RequireInt`/`OptionalInt` methods handle +conversion automatically. + +| Method | Signature | Description | +|--------|-----------|-------------| +| `RequireString` | `(key string) (string, error)` | Returns a string or error if missing/wrong type. | +| `OptionalString` | `(key, defaultValue string) string` | Returns a string or the default. | +| `RequireInt` | `(key string) (int, error)` | Returns an int or error if missing/wrong type. | +| `OptionalInt` | `(key string, defaultValue int) int` | Returns an int or the default. | +| `OptionalBool` | `(key string, defaultValue bool) bool` | Returns a bool or the default. | +| `OptionalFloat` | `(key string, defaultValue float64) float64` | Returns a float64 or the default. | +| `Has` | `(key string) bool` | True if the key exists in the arguments. | +| `Raw` | `() map[string]interface{}` | Returns the underlying argument map. | + +### ParseToolArgs + +```go +func ParseToolArgs(request mcp.CallToolRequest) ToolArgs +``` + +Extracts the arguments map from an MCP `CallToolRequest`. + +**Usage:** + +```go +func listHandler(ctx context.Context, args azdext.ToolArgs) (*mcp.CallToolResult, error) { + rg, err := args.RequireString("resourceGroup") + if err != nil { + return azdext.MCPErrorResult("missing required argument: %v", err), nil + } + sub := args.OptionalString("subscription", "") + limit := args.OptionalInt("limit", 50) + + // ... perform operation ... + + return azdext.MCPJSONResult(results), nil +} +``` + +--- + +## MCP Result Helpers + +### MCPTextResult + +```go +func MCPTextResult(format string, args ...interface{}) *mcp.CallToolResult +``` + +Creates a text-content `CallToolResult` using `fmt.Sprintf` formatting. + +### MCPJSONResult + +```go +func MCPJSONResult(data interface{}) *mcp.CallToolResult +``` + +Marshals `data` to JSON and creates a text-content `CallToolResult`. Returns an +error result if marshaling fails. + +### MCPErrorResult + +```go +func MCPErrorResult(format string, args ...interface{}) *mcp.CallToolResult +``` + +Creates an error `CallToolResult` with `IsError` set to `true`. + +--- + +## MCP Security Policy + +The `MCPSecurityPolicy` validates URLs and file paths used by MCP tool calls to +prevent SSRF, directory traversal, and data exfiltration. + +### NewMCPSecurityPolicy + +```go +func NewMCPSecurityPolicy() *MCPSecurityPolicy +``` + +Creates an empty security policy. Chain methods to build up the desired rules. + +### DefaultMCPSecurityPolicy + +```go +func DefaultMCPSecurityPolicy() *MCPSecurityPolicy +``` + +Returns a policy with recommended defaults: + +- Cloud metadata endpoints blocked (AWS, GCP, Azure IMDS). +- RFC 1918 private networks blocked. +- HTTPS required (except localhost/127.0.0.1). +- Common sensitive headers redacted (`Authorization`, `Cookie`, `X-Api-Key`, etc.). + +### MCPSecurityPolicy Methods + +| Method | Signature | Description | +|--------|-----------|-------------| +| `BlockMetadataEndpoints` | `() *MCPSecurityPolicy` | Block cloud metadata service endpoints (`169.254.169.254`, `fd00:ec2::254`, `metadata.google.internal`, etc.). | +| `BlockPrivateNetworks` | `() *MCPSecurityPolicy` | Block RFC 1918 private networks, loopback, link-local, CGNAT (RFC 6598), and deprecated IPv6 transition mechanisms. | +| `RequireHTTPS` | `() *MCPSecurityPolicy` | Require HTTPS for all URLs except `localhost`/`127.0.0.1`. | +| `RedactHeaders` | `(headers ...string) *MCPSecurityPolicy` | Mark headers that should be blocked/redacted in outgoing requests. | +| `ValidatePathsWithinBase` | `(basePaths ...string) *MCPSecurityPolicy` | Restrict file paths to the given base directories. Resolves symlinks and blocks `../` traversal. | +| `CheckURL` | `(rawURL string) error` | Validate a URL against the policy. Returns `nil` if allowed. | +| `CheckPath` | `(path string) error` | Validate a file path against the policy. | +| `IsHeaderBlocked` | `(header string) bool` | Check if a header name is in the redacted set. | + +**Usage:** + +```go +policy := azdext.NewMCPSecurityPolicy(). + BlockMetadataEndpoints(). + BlockPrivateNetworks(). + RequireHTTPS(). + RedactHeaders("Authorization", "X-Custom-Secret"). + ValidatePathsWithinBase("/home/user/project") + +if err := policy.CheckURL(userProvidedURL); err != nil { + return azdext.MCPErrorResult("blocked URL: %v", err), nil +} +``` + +--- + +## Service Target Providers + +### ServiceTargetProvider Interface + +```go +type ServiceTargetProvider interface { + Initialize(ctx context.Context, serviceConfig *ServiceConfig) error + Endpoints(ctx context.Context, serviceConfig *ServiceConfig, targetResource *TargetResource) ([]string, error) + GetTargetResource(ctx context.Context, subscriptionId string, serviceConfig *ServiceConfig, defaultResolver func() (*TargetResource, error)) (*TargetResource, error) + Package(ctx context.Context, serviceConfig *ServiceConfig, serviceContext *ServiceContext, progress ProgressReporter) (*ServicePackageResult, error) + Publish(ctx context.Context, serviceConfig *ServiceConfig, serviceContext *ServiceContext, targetResource *TargetResource, publishOptions *PublishOptions, progress ProgressReporter) (*ServicePublishResult, error) + Deploy(ctx context.Context, serviceConfig *ServiceConfig, serviceContext *ServiceContext, targetResource *TargetResource, progress ProgressReporter) (*ServiceDeployResult, error) +} +``` + +### BaseServiceTargetProvider + +```go +type BaseServiceTargetProvider struct{} +``` + +Provides **no-op default implementations** for all `ServiceTargetProvider` +methods. Extensions should embed this struct and override only the methods they +need. + +**Usage:** + +```go +type MyProvider struct { + azdext.BaseServiceTargetProvider // embed defaults + client *azdext.AzdClient +} + +// Override only what you need +func (p *MyProvider) Package(ctx context.Context, sc *azdext.ServiceConfig, + sctx *azdext.ServiceContext, progress azdext.ProgressReporter, +) (*azdext.ServicePackageResult, error) { + progress.Report("Packaging...") + // custom packaging logic + return &azdext.ServicePackageResult{PackagePath: "/out/app.tar.gz"}, nil +} +``` + +--- + +## Extension Host + +### NewExtensionHost + +```go +func NewExtensionHost(client *AzdClient) *ExtensionHost +``` + +Creates an `ExtensionHost` that manages service targets, framework services, +and event handlers. The host starts a gRPC listener and blocks until azd shuts +down the connection. + +### ExtensionHost Methods + +| Method | Signature | Description | +|--------|-----------|-------------| +| `Client` | `() *AzdClient` | Returns the underlying gRPC client. | +| `WithServiceTarget` | `(host string, factory ServiceTargetFactory) *ExtensionHost` | Register a custom deployment target. | +| `WithFrameworkService` | `(language string, factory FrameworkServiceFactory) *ExtensionHost` | Register a custom language/framework build service. | +| `WithProjectEventHandler` | `(eventName string, handler ProjectEventHandler) *ExtensionHost` | Register a project-level lifecycle event handler. | +| `WithServiceEventHandler` | `(eventName string, handler ServiceEventHandler, options *ServiceEventOptions) *ExtensionHost` | Register a service-level lifecycle event handler (with optional filtering). | +| `Run` | `(ctx context.Context) error` | Start the host and block until shutdown. | + +--- + +## Client & Utilities + +### AzdClient + +```go +func NewAzdClient(opts ...AzdClientOption) (*AzdClient, error) +``` + +gRPC client connecting to the azd framework. Auto-discovers the socket via +`AZD_RPC_SERVER_ENDPOINT`. Provides typed accessors for all framework services: + +| Accessor | Returns | +|----------|---------| +| `Project()` | `ProjectServiceClient` | +| `Environment()` | `EnvironmentServiceClient` | +| `UserConfig()` | `UserConfigServiceClient` | +| `Prompt()` | `PromptServiceClient` | +| `Deployment()` | `DeploymentServiceClient` | +| `Events()` | `EventServiceClient` | +| `Compose()` | `ComposeServiceClient` | +| `Workflow()` | `WorkflowServiceClient` | +| `ServiceTarget()` | `ServiceTargetServiceClient` | +| `FrameworkService()` | `FrameworkServiceClient` | +| `Container()` | `ContainerServiceClient` | +| `Extension()` | `ExtensionServiceClient` | +| `Account()` | `AccountServiceClient` | +| `Ai()` | `AiModelServiceClient` | + +Always call `defer client.Close()` after creation. + +### ConfigHelper + +```go +func NewConfigHelper(client *AzdClient) (*ConfigHelper, error) +``` + +Provides read/write access to azd user and environment configuration: + +| Method | Description | +|--------|-------------| +| `GetUserString(ctx, path)` | Read a string from user config. | +| `GetUserJSON(ctx, path, out)` | Unmarshal user config into a struct. | +| `SetUserJSON(ctx, path, value)` | Write a value to user config. | +| `UnsetUser(ctx, path)` | Remove a user config key. | +| `GetEnvString(ctx, path)` | Read a string from env config. | +| `GetEnvJSON(ctx, path, out)` | Unmarshal env config into a struct. | +| `SetEnvJSON(ctx, path, value)` | Write a value to env config. | +| `UnsetEnv(ctx, path)` | Remove an env config key. | + +Utility functions: + +- `MergeJSON(base, override)` — Shallow-merge two JSON maps. +- `DeepMergeJSON(base, override)` — Deep recursive merge. +- `ValidateConfig(path, data, validators...)` — Validate config data. +- `RequiredKeys(keys...)` — Returns a `ConfigValidator` that checks for required keys. + +### TokenProvider + +```go +func NewTokenProvider(ctx context.Context, client *AzdClient, opts *TokenProviderOptions) (*TokenProvider, error) +``` + +Obtains Azure access tokens for authenticated API calls. Implements +`azcore.TokenCredential` semantics. + +| Method | Description | +|--------|-------------| +| `GetToken(ctx, options)` | Returns an `azcore.AccessToken`. | +| `TenantID()` | Returns the resolved tenant ID. | + +### Logger + +```go +func NewLogger(component string, opts ...LoggerOptions) *Logger +``` + +Structured logging with component tagging, backed by `slog`: + +| Method | Description | +|--------|-------------| +| `Debug(msg, args...)` | Log at DEBUG level. | +| `Info(msg, args...)` | Log at INFO level. | +| `Warn(msg, args...)` | Log at WARN level. | +| `Error(msg, args...)` | Log at ERROR level. | +| `With(args...)` | Create a child logger with additional fields. | +| `WithComponent(name)` | Create a child logger for a sub-component. | +| `WithOperation(name)` | Create a child logger tagged with an operation name. | +| `Slogger()` | Return the underlying `*slog.Logger`. | + +Call `azdext.SetupLogging(LoggerOptions{Debug: true})` during initialization to +configure the global log level. + +### Output + +```go +func NewOutput(opts OutputOptions) *Output +``` + +Format-aware output (text or JSON): + +| Method | Description | +|--------|-------------| +| `IsJSON()` | True if output format is JSON. | +| `Success(fmt, args...)` | Print a success message (green). | +| `Warning(fmt, args...)` | Print a warning (yellow). | +| `Error(fmt, args...)` | Print an error (red). | +| `Info(fmt, args...)` | Print informational text. | +| `Message(fmt, args...)` | Print plain text. | +| `JSON(data)` | Marshal and print JSON. | +| `Table(headers, rows)` | Print a formatted table. | + +### Runtime Utilities + +These helpers are intended to remove common extension boilerplate for shell execution, tool checks, TTY detection, and safe file writes. + +#### Shell Helpers + +| API | Description | +|-----|-------------| +| `DetectShell()` | Detects the current shell using `SHELL`, `PSModulePath`, `ComSpec`, then platform defaults. | +| `ShellCommand(ctx, script)` | Builds an `exec.Cmd` using detected shell conventions (`cmd /C`, `pwsh -Command`, ` -c`). | +| `ShellCommandWith(ctx, info, script)` | Same as `ShellCommand` but uses explicit `ShellInfo` for deterministic behavior/testing. | +| `IsInteractiveTerminal(f)` / `IsStdinTerminal()` / `IsStdoutTerminal()` | Terminal detection helpers. | + +#### Tool Discovery Helpers + +| API | Description | +|-----|-------------| +| `LookupTool(name)` | Looks up tools on `PATH` and also checks the current project directory for local wrappers (for example `./mvnw`). | +| `LookupTools(names...)` | Batch lookup for multiple tools. | +| `RequireTools(names...)` | Returns a typed error when required tools are missing. | +| `PrependPATH` / `AppendPATH` / `PATHContains` | Cross-platform `PATH` mutation and detection helpers. | + +#### Interactive/TUI Helpers + +| API | Description | +|-----|-------------| +| `DetectInteractive()` | Detects TTY mode (`full` / `limited` / `none`), `AZD_NO_PROMPT`, CI, and known agent environments. | +| `InteractiveInfo.CanPrompt()` | Safe prompt gate (`stdin/stdout tty`, not no-prompt, not CI, not agent). | +| `InteractiveInfo.CanColorize()` | Color output gate honoring `FORCE_COLOR` and `NO_COLOR`. | + +#### Atomic File Helpers + +| API | Description | +|-----|-------------| +| `WriteFileAtomic(path, data, perm)` | Writes via temp-file + atomic rename, with Windows rename retry behavior. | +| `CopyFileAtomic(src, dst, perm)` | Atomic copy via `WriteFileAtomic`. | +| `BackupFile(path, suffix)` | Creates an atomic backup file (`.bak` by default). | +| `EnsureDir(dir, perm)` | Convenience wrapper around `os.MkdirAll` with extension-prefixed errors. | + +#### Process Management Helpers + +Cross-platform utilities for checking and inspecting running processes. + +| API | Description | +|-----|-------------| +| `IsProcessRunning(pid)` | Reports whether a process with the given PID is alive. Uses signal 0 on Unix and `OpenProcess` on Windows. | +| `GetProcessInfo(pid)` | Retrieves name and executable path for a process PID. Reads `/proc` on Linux, uses `ps` on macOS, `QueryFullProcessImageName` on Windows. | +| `CurrentProcessInfo()` | Returns `ProcessInfo` for the current process. | +| `ParentProcessInfo()` | Returns `ProcessInfo` for the parent process. | +| `FindProcessByName(name)` | Returns all running processes whose executable basename matches `name`. | +| `GetProcessEnvironment()` | Returns a `ProcessEnvironment` containing the current process's environment variables as a typed map. | + +**`ProcessInfo` fields:** + +```go +type ProcessInfo struct { + PID int // process identifier + Name string // executable basename + Executable string // full path to executable, if available + Running bool // true if the process is alive +} +``` + +**Usage:** + +```go +// Check if a known service process is still running +if !azdext.IsProcessRunning(savedPID) { + log.Println("service has stopped") +} + +// Guard against PID reuse by verifying the process name +info := azdext.GetProcessInfo(savedPID) +if !info.Running || info.Name != "myservice" { + return fmt.Errorf("expected service process not running") +} +``` + +#### Environment Loading Helpers + +Helpers for loading azd environment variables into extensions without calling +`azd env get-values` manually. + +| API | Description | +|-----|-------------| +| `LoadAzdEnvironment(ctx)` | Runs `azd env get-values` and returns the result as a `map[string]string`. | +| `ParseEnvironmentVariables(lines)` | Parses a `KEY=VALUE` text slice into a map. Strips surrounding double quotes and skips blank/comment lines. | + +**Usage:** + +```go +env, err := azdext.LoadAzdEnvironment(ctx) +if err != nil { + return fmt.Errorf("failed to load azd environment: %w", err) +} + +dbURL := env["DATABASE_URL"] +``` + +#### Project Resolution Helpers + +Helpers for locating the azd project directory from within an extension process. + +| API | Description | +|-----|-------------| +| `GetProjectDir()` | Returns the azd project directory. Checks `AZD_EXEC_PROJECT_DIR` first, then walks up from cwd searching for `azure.yaml`. Returns `ErrProjectNotFound` if not found. | +| `FindFileUpward(startDir, fileName)` | Walks up from `startDir` looking for `fileName` and returns the containing directory. | + +**Usage:** + +```go +projectDir, err := azdext.GetProjectDir() +if err != nil { + return fmt.Errorf("could not locate azd project: %w", err) +} +configPath := filepath.Join(projectDir, "myextension.json") +``` + +#### Security Validation Helpers + +Input validation utilities for safe handling of user-supplied names and paths. + +| API | Description | +|-----|-------------| +| `ValidateServiceName(name)` | Validates that `name` is a DNS-safe service identifier (alphanumeric, `.`, `_`, `-`; 1–63 chars; starts with alphanumeric). | +| `ValidateHostname(hostname)` | Validates a hostname or IP address string. | +| `ValidateScriptName(name)` | Validates a script file name (alphanumeric, hyphens, underscores, dots; 1–64 chars; no path separators). | +| `IsContainerEnvironment()` | Reports whether the process is running inside a container (Docker, Podman, Kubernetes). | +| `ContainerRuntime()` | Returns the detected container runtime name (`"docker"`, `"podman"`, etc.), or `""` if not in a container. | + +All validation functions return a `*ValidationError` on failure: + +```go +type ValidationError struct { + Field string // logical input name (e.g. "service_name") + Value string // rejected value + Rule string // machine-readable constraint tag + Message string // human-readable explanation +} +``` + +**Usage:** + +```go +if err := azdext.ValidateServiceName(userInput); err != nil { + return fmt.Errorf("invalid service name: %w", err) +} +``` + +#### SSRF Guard + +`SSRFGuard` provides HTTP URL validation to prevent Server-Side Request +Forgery attacks from extension code that makes outbound HTTP calls on behalf +of user input. + +> **Note:** For MCP tools, use [MCPSecurityPolicy](#mcp-security-policy) instead, +> which integrates directly with `MCPServerBuilder`. +> `SSRFGuard` is for general-purpose HTTP clients in non-MCP extension code. + +| API | Description | +|-----|-------------| +| `NewSSRFGuard()` | Creates an empty guard. Chain methods to configure rules. | +| `DefaultSSRFGuard()` | Returns a guard with recommended defaults: metadata endpoints blocked, private networks blocked, HTTPS required. | +| `BlockMetadataEndpoints()` | Blocks AWS, GCP, and Azure IMDS endpoints (`169.254.169.254`, etc.). | +| `BlockPrivateNetworks()` | Blocks RFC 1918 private networks, loopback, link-local, and CGNAT ranges. | +| `RequireHTTPS()` | Requires HTTPS for all URLs (localhost/127.0.0.1 are exempted). | +| `AllowHost(hosts...)` | Explicitly allowlists specific hostnames (bypasses blocking rules). | +| `OnBlocked(fn)` | Registers a callback invoked when a URL is blocked (useful for logging). | +| `Check(rawURL)` | Validates a URL against all configured rules. Returns `*SSRFError` if blocked. | + +**Usage:** + +```go +guard := azdext.DefaultSSRFGuard(). + AllowHost("my-trusted-endpoint.example.com") + +if err := guard.Check(userSuppliedURL); err != nil { + return fmt.Errorf("blocked URL: %w", err) +} + +resp, err := http.Get(userSuppliedURL) +``` + +#### Testing Helpers + +Utilities for capturing CLI output in extension tests. + +| API | Description | +|-----|-------------| +| `CaptureOutput(fn)` | Runs `fn` and captures its writes to `os.Stdout` and `os.Stderr`. Returns `(stdout, stderr string, err error)`. Restores original file descriptors even if `fn` panics. | + +**Usage:** + +```go +func TestMyCommand(t *testing.T) { + stdout, stderr, err := azdext.CaptureOutput(func() { + myCommand.Execute() + }) + require.NoError(t, err) + assert.Contains(t, stdout, "expected output") + assert.Empty(t, stderr) +} +``` + +--- + +## Key Vault Secret Resolution + +The `KeyVaultResolver` enables extensions to resolve Azure Key Vault secret +references in environment variables without writing custom Key Vault code. +It was added in [PR #7043](https://github.com/Azure/azure-dev/pull/7043). + +### KeyVaultResolver + +`KeyVaultResolver` resolves Key Vault secret references using the extension's +`TokenProvider` for authentication and the Azure SDK data-plane client for +secret retrieval. + +Three reference formats are supported: + +| Format | Example | +|--------|---------| +| `akvs://` (compact, preferred) | `akvs://sub-id/my-vault/my-secret` | +| `@Microsoft.KeyVault(SecretUri=...)` | `@Microsoft.KeyVault(SecretUri=https://my-vault.vault.azure.net/secrets/my-secret)` | +| `@Microsoft.KeyVault(VaultName=...;SecretName=...)` | `@Microsoft.KeyVault(VaultName=my-vault;SecretName=my-secret)` | + +### NewKeyVaultResolver + +```go +func NewKeyVaultResolver( + credential azcore.TokenCredential, + opts *KeyVaultResolverOptions, +) (*KeyVaultResolver, error) +``` + +Creates a `KeyVaultResolver`. `credential` must not be nil — use a +`*TokenProvider` obtained from `NewTokenProvider`. If `opts` is nil, production +defaults (Azure public cloud) are used. + +```go +type KeyVaultResolverOptions struct { + // VaultSuffix overrides the Key Vault DNS suffix. + // Defaults to "vault.azure.net" (Azure public cloud). + VaultSuffix string +} +``` + +### KeyVaultResolver Methods + +| Method | Signature | Description | +|--------|-----------|-------------| +| `Resolve` | `(ctx, ref string) (string, error)` | Resolves a single secret reference and returns the plaintext value. | +| `ResolveMap` | `(ctx, refs map[string]string) (map[string]string, error)` | Resolves all secret references in a map. Plain (non-reference) values are passed through unchanged. Errors are collected and joined so all failures are reported at once. | + +**`ResolveEnvironment` helper:** + +```go +func (r *KeyVaultResolver) ResolveMap(ctx context.Context, refs map[string]string) (map[string]string, error) +``` + +Pass the full environment map returned by `LoadAzdEnvironment` and only +references are resolved — plain values pass through unchanged. + +**Usage:** + +```go +// Set up resolver +tp, err := azdext.NewTokenProvider(ctx, client, nil) +if err != nil { + return fmt.Errorf("failed to create token provider: %w", err) +} + +resolver, err := azdext.NewKeyVaultResolver(tp, nil) +if err != nil { + return fmt.Errorf("failed to create KV resolver: %w", err) +} + +// Resolve a single reference +value, err := resolver.Resolve(ctx, "akvs://my-sub/my-vault/my-secret") +if err != nil { + return fmt.Errorf("secret resolution failed: %w", err) +} + +// Resolve all secret references in the azd environment +env, _ := azdext.LoadAzdEnvironment(ctx) +resolved, err := resolver.ResolveMap(ctx, env) +if err != nil { + return fmt.Errorf("some secrets could not be resolved: %w", err) +} +dbPassword := resolved["DB_PASSWORD"] +``` + +### SecretReference + +```go +type SecretReference struct { + SubscriptionID string // present for akvs:// references + VaultName string // Key Vault name + SecretName string // secret name within the vault + SecretVersion string // specific version; empty = latest + VaultURL string // full vault URL (present for @Microsoft.KeyVault(SecretUri=...) references) +} +``` + +Represents a parsed Key Vault secret reference before resolution. + +### IsSecretReference / ParseSecretReference + +```go +func IsSecretReference(s string) bool +func ParseSecretReference(ref string) (*SecretReference, error) +``` + +`IsSecretReference` detects all three supported formats (with leading/trailing +whitespace and surrounding quotes stripped). Use it to check whether an +environment variable value needs resolution before calling `Resolve`. + +`ParseSecretReference` parses a reference string into its components. Returns +an error if the format is invalid or required fields are missing. + +### KeyVaultResolveError + +```go +type KeyVaultResolveError struct { + Reference string // original reference string + Reason ResolveReason + Err error +} + +type ResolveReason string + +const ( + ResolveReasonInvalidReference ResolveReason = "invalid_reference" + ResolveReasonClientCreation ResolveReason = "client_creation" + ResolveReasonNotFound ResolveReason = "not_found" + ResolveReasonAccessDenied ResolveReason = "access_denied" + ResolveReasonServiceError ResolveReason = "service_error" +) +``` + +`KeyVaultResolveError` is returned by `Resolve` and (wrapped) by `ResolveMap` +for all domain errors. Use `errors.As` to inspect the reason: + +```go +value, err := resolver.Resolve(ctx, ref) +if err != nil { + var kvErr *azdext.KeyVaultResolveError + if errors.As(err, &kvErr) { + switch kvErr.Reason { + case azdext.ResolveReasonNotFound: + return fmt.Errorf("secret %q does not exist", ref) + case azdext.ResolveReasonAccessDenied: + return fmt.Errorf("no permission to read secret %q", ref) + } + } + return err +} +``` + +--- + +## Error Handling + +### LocalError + +```go +type LocalError struct { + Message string + Code string + Category LocalErrorCategory + Suggestion string +} +``` + +Represents an error originating within the extension. The `Suggestion` field +provides actionable guidance displayed to the user. + +### ServiceError + +```go +type ServiceError struct { + Message string + ErrorCode string + StatusCode int + ServiceName string + Suggestion string +} +``` + +Represents an error from an Azure service call. + +### LocalErrorCategory + +```go +type LocalErrorCategory string + +const ( + LocalErrorCategoryValidation LocalErrorCategory = "validation" + LocalErrorCategoryAuth LocalErrorCategory = "auth" + LocalErrorCategoryDependency LocalErrorCategory = "dependency" + LocalErrorCategoryCompatibility LocalErrorCategory = "compatibility" + LocalErrorCategoryUser LocalErrorCategory = "user" + LocalErrorCategoryInternal LocalErrorCategory = "internal" + LocalErrorCategoryLocal LocalErrorCategory = "local" +) +``` + +Error categories enable structured telemetry classification and targeted error +guidance. Use `WrapError(err)` to convert a `LocalError` or `ServiceError` to +the gRPC `ExtensionError` proto for reporting. + +--- + +## See Also + +- [Extension Framework](./extension-framework.md) — Getting started, managing extensions, developing extensions. +- [Extension Migration Guide](./extension-migration-guide.md) — Migrate from pre-#6856 patterns to new SDK helpers. +- [Extension End-to-End Walkthrough](./extension-e2e-walkthrough.md) — Build a complete extension from scratch. +- [Extension Framework Services](./extension-framework-services.md) — gRPC service reference for custom language frameworks. +- [Extension Style Guide](./extensions-style-guide.md) — Design guidelines and best practices.