This guide provides comprehensive instructions for creating new Tool classes in the azsdk-cli project. These tools serve dual purposes: they can be invoked via the command-line interface (CLI) and exposed through the Model Context Protocol (MCP) server for LLM coding agents.
New tools can be created with copilot chat/agent:
Help me create a new tool using #new-tool.md as a reference
- Tool Architecture Overview
- Step-by-Step Implementation Guide
- Code Examples and Templates
- Dependency Injection
- Response Handling
- Registration and Testing
- Required Tool Conventions
- Common Patterns and Anti-patterns
- CLI Command Hierarchy
All tools in the azsdk-cli project follow a consistent architecture:
- Base Class: All tools inherit from
MCPTool(defined inAzure.Sdk.Tools.Cli.Contract) - Namespace: Tools should be in namespace
Azure.Sdk.Tools.Cli.Tools - Location: Tool files are organized under
Azure.Sdk.Tools.Cli/Tools/in logical groupings - Attributes: Tools are decorated with
[McpServerToolType]for discovery - Dual Interface: Tools support both CLI commands and MCP server methods
- Class Declaration: Inherits from
MCPToolwith appropriate attributes - Constructor: Uses dependency injection to receive required services
- Command Configuration: CLI options, arguments, and command hierarchy
- CLI Handler:
GetCommand()andHandleCommand()methods - MCP Methods: Methods decorated with
[McpServerTool]for LLM access - Error Handling: Comprehensive try/catch blocks and response error management
Questions to Consider:
- What is the primary function of your tool?
- Does it fit into an existing command group or need a new one?
- Does it fit into an existing namespace based on the primary function?
- What should the CLI command structure look like?
Naming Conventions:
- Class Name:
{FunctionalName}Tool(e.g.,LogAnalysisTool,PipelineAnalysisTool) - File Location:
Tools/{Category}/{ToolName}.csorTools/{ToolName}.cs - Namespace:
Azure.Sdk.Tools.Cli.Tools.{Category}(namespace category should be choosen based on the primary function)
Command Groups (defined in SharedCommandGroups.cs):
AzurePipelines- Azure DevOps pipeline operations (azsdk azp)EngSys- Engineering system commands (azsdk eng)Generators- File generation commands (azsdk generators)Cleanup- Resource cleanup commands (azsdk cleanup)Log- Log processing commands (azsdk log)
Command Hierarchy Examples:
// Single group: azsdk log analyze
CommandHierarchy = [ SharedCommandGroups.Log ];
// Multiple groups: azsdk eng cleanup agents
CommandHierarchy = [ SharedCommandGroups.EngSys, SharedCommandGroups.Cleanup ];Decision Points:
- Arguments: Required positional parameters (e.g., file paths, IDs)
- Options: Optional flags and parameters with default values
- Sub-commands: Does your tool need multiple operations?
Shared Options (refer to SharedOptions.cs) for options used broadly across commands
Consider:
- What methods should be exposed to LLM agents?
- What parameters do they need?
- How should responses be structured?
- What error conditions need handling?
Common Dependencies:
ILogger<YourTool>- Always required for loggingIOutputHelper- Required for CLI output (final results only)IAzureService- For Azure authentication and credentialsIDevOpsService- For Azure DevOps operations- Custom service interfaces for your tool's specific needs
A working example of multiple tool types and usage of services can be found at ExampleTool.cs
Additional documents exist that detail more specific scenarios:
In Azure.Sdk.Cli.Tools.Cli/Tools/YourToolCategory/YourTool.cs:
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
using System.CommandLine;
using System.CommandLine.Invocation;
using System.ComponentModel;
using Azure.Sdk.Tools.Cli.Commands;
using Azure.Sdk.Tools.Cli.Contract;
using Azure.Sdk.Tools.Cli.Models;
using Azure.Sdk.Tools.Cli.Services;
using ModelContextProtocol.Server;
namespace Azure.Sdk.Tools.Cli.Tools.YourToolCategory;
[McpServerToolType, Description("Brief description of what this tool does")]
public class YourTool : MCPTool
{
// Dependencies (injected via constructor)
private readonly ILogger<YourTool> logger;
private readonly IOutputHelper output;
// CLI Options and Arguments
private readonly Argument<string> requiredArg = new Argument<string>(
name: "input",
description: "Description of required argument"
) { Arity = ArgumentArity.ExactlyOne };
private readonly Option<string> optionalParam = new(["--param", "-p"], "Optional parameter description");
private readonly Option<bool> flagOption = new(["--flag", "-f"], () => false, "Boolean flag description");
// Constructor with dependency injection
public YourTool(
ILogger<YourTool> logger,
IOutputHelper output
// Add other dependencies as needed
) : base()
{
this.logger = logger;
this.output = output;
// Set command hierarchy - determines CLI command path
CommandHierarchy = [
SharedCommandGroups.YourGroup // Results in: azsdk yourgroup yourcommand
];
}
// CLI Command Configuration
public override Command GetCommand()
{
var command = new Command("your-command", "Description for CLI help");
command.AddArgument(requiredArg);
command.AddOption(optionalParam);
command.AddOption(flagOption);
command.SetHandler(async ctx => { await HandleCommand(ctx, ctx.GetCancellationToken()); });
return command;
}
// CLI Command Handler
public override async Task HandleCommand(InvocationContext ctx, CancellationToken ct)
{
// Extract parameters from CLI context
var input = ctx.ParseResult.GetValueForArgument(requiredArg);
var param = ctx.ParseResult.GetValueForOption(optionalParam);
var flag = ctx.ParseResult.GetValueForOption(flagOption);
// Call your main logic (can be shared with MCP methods)
var result = await ProcessRequest(input, param, flag, ct);
// Set exit code and output result
ctx.ExitCode = ExitCode;
output.Output(result);
}
// MCP Server Method - exposed to LLM agents
[McpServerTool(Name = "your_tool_method"), Description("Description for LLM agents")]
public async Task<YourResponseType> ProcessRequest(string input, string? optionalParam = null, CancellationToken ct = default)
{
try
{
return await ProcessRequest(input, optionalParam, false, ct);
}
catch (Exception ex)
{
logger.LogError(ex, "Error processing MCP request: {input}", input);
SetFailure();
return new YourResponseType
{
ResponseError = $"Error processing request: {ex.Message}"
};
}
}
}[McpServerToolType, Description("Tool with multiple sub-commands")]
public class ComplexTool(ILogger<ComplexTool> logger, IOutputHelper output) : MCPTool
{
private const string SubCommandName1 = "sub-command-1";
private const string SubCommandName2 = "sub-command-2";
private readonly Option<string> fooOption = new(["--foo"], "Foo") { IsRequired = true };
private readonly Option<string> barOption = new(["--bar"], "Bar");
public override Command GetCommand()
{
// Create parent command
var parentCommand = new Command("complex", "Complex tool with sub-commands");
// Sub-command 1
var scmd1 = new Command(SubCommandName1, "Analyze something");
scmd1.AddOption(fooOption);
scmd1.SetHandler(async ctx => { await HandleCommand(ctx, ctx.GetCancellationToken()); });
// Sub-command 2
var scmd2 = new Command(SubCommandName2, "Process something");
scmd2.AddOption(fooOption, barOption);
scmd2.SetHandler(async ctx => { await HandleCommand(ctx, ctx.GetCancellationToken()); });
parentCommand.Add(scmd1);
parentCommand.Add(scmd2);
return parentCommand;
}
public override async Task HandleCommand(InvocationContext ctx, CancellationToken ct)
{
var commandName = ctx.ParseResult.CommandResult.Command.Name;
if (commandName == SubCommandName1)
{
var foo = ctx.ParseResult.GetValueForOption(fooOption);
var result1 = await SubCommand1(foo, ct);
ctx.ExitCode = ExitCode;
output.Output(result1);
}
if (commandName == SubCommandName2)
{
var foo = ctx.ParseResult.GetValueForOption(fooOption);
var bar = ctx.ParseResult.GetValueForOption(barOption);
var result2 = await SubCommand2(foo, bar, ct);
ctx.ExitCode = ExitCode;
output.Output(result2);
}
}
[McpServerTool(Name = "sub_command_1"), Description("Handles first stuff")]
public async Task<DefaultCommandResponse> SubCommand1(string foo, CancellationToken ct)
{
// Implementation
}
[McpServerTool(Name = "sub_command_2"), Description("Handles second stuff")]
public async Task<YourResponseType> SubCommand2(string foo, string bar, CancellationToken ct)
{
// Implementation
}
}public YourTool(
ILogger<YourTool> logger, // Logging - ALWAYS required
IOutputHelper output, // CLI output - required for CLI commands
IAzureService azureService, // Azure credentials and authentication
IDevOpsService devopsService, // Azure DevOps operations
IAzureAgentServiceFactory agentServiceFactory, // AI services factory
IYourCustomService customService // Your domain-specific services
) : base()- ILogger: Use for all logging operations (Info, Warning, Error, Debug)
- IOutputHelper: Use ONLY in
GetCommand()for final CLI output to terminal/MCP client - IAzureService: Get Azure credentials, authenticate with Azure services
- Custom Services: Implement business logic in separate services, not in tools
- Avoid direct
usingstatements for external dependencies in tools - Use injected services only to maintain testability and loose coupling
- Don't call other Tool classes directly - use shared service or helper classes instead
Response handling strategies were created with the intent to flexibly handle multiple different types of callers without the output being too tightly coupled to the tool code. Calls could be from a CLI invocation in the terminal, tool calls from an MCP client, and potentially more.
A custom response class is not always necessary. It should be defined when the tool needs to:
- Define formatting rules for complex output data
- Return structured data that is easier for an LLM to parse
- Enforce specific fields get set in output
- Customize error output
Tools can also return primitive types, string, IEnumerable of a supported type, Task and void for simple scenarios.
All tool response classes must:
- Inherit from
Responsebase class - Override
ToString()to format properties in a human readable way and return the baseToString()method to handle error formatting. - Set JSON serializer attributes on all properties.
Tools that may have error cases but no need for a custom type should use DefaultCommandResponse as
the return type. The .Result property takes object, but it may not
serialize or stringify correctly if ToString() is not overridden.
To define a response class, add to Azure.Sdk.Tools.Cli/Models/Responses/:
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
using System.Text.Json.Serialization;
namespace Azure.Sdk.Tools.Cli.Models;
// Response class - must inherit from Response
public class YourResponseType : Response
{
[JsonPropertyName("result")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Result { get; set; }
[JsonPropertyName("message")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Message { get; set; }
public override string ToString()
{
var output = new StringBuilder();
if (!string.IsNullOrEmpty(Message))
{
output.AppendLine($"Message: {Message}");
}
if (Result != null)
{
output.AppendLine($"Result: {Result?.ToString() ?? "null"}");
}
return ToString(output);
}
}An example usage of DefaultCommandResponse:
[McpServerTool(Name = "hello-world"), Description("Echoes the message back to the client")]
public DefaultCommandResponse EchoSuccess(string message)
{
try
{
logger.LogInformation("Echoing message: {message}", message);
return new()
{
Result = $"RESPONDING TO '{message}' with SUCCESS: {ExitCode}"
};
}
catch (Exception ex)
{
logger.LogError(ex, "Error occurred while echoing message: {message}", message);
SetFailure();
return new()
{
ResponseError = $"Error occurred while processing '{message}': {ex.Message}"
};
}
}// Single error
catch (Exception ex)
{
logger.LogError(ex, "Error processing {input}", input);
SetFailure();
return new YourResponseType
{
ResponseError = $"Failed to process {input}: {ex.Message}"
};
}
// Multiple errors
var errors = new List<string>();
// ... collect errors
if (errors.Any())
{
SetFailure();
return new YourResponseType
{
ResponseErrors = errors
};
}Add your tool to the SharedOptions.ToolsList in Commands/SharedOptions.cs:
public static readonly List<Type> ToolsList = [
// ... existing tools
typeof(YourTool), // Add your tool here
// ... more tools
];From [repo root]/tools/azsdk-cli
Build the project:
dotnet build
Test CLI functionality:
dotnet run --project Azure.Sdk.Tools.Cli -- yourgroup yourcommand --help
dotnet run --project Azure.Sdk.Tools.Cli -- yourgroup yourcommand input-value --param value
Test MCP functionality:
Start the MCP server in your MCP client and run the tool via #my-tool-name some args here
Run unit tests:
dotnet test
using Moq;
using Azure.Sdk.Tools.Cli.Tools;
namespace Azure.Sdk.Tools.Cli.Tests;
internal class YourToolTests
{
[Test]
public async Task YourTool_ProcessInput_ReturnsExpectedResult()
{
// Arrange
var logger = new Mock<ILogger<YourTool>>();
var outputHelper = new Mock<IOutputHelper>();
var tool = new YourTool(logger.Object, outputHelper.Object);
// Act
var result = await tool.YourToolMethod("test-input");
// Assert
Assert.That(result.ResponseError, Is.Null);
Assert.That(result.Result, Is.Not.Null);
}
}- Always wrap top-level methods in try/catch blocks
- Use specific exception types when possible for better error messages
- Log errors with context before returning error responses
- Call
SetFailure()on tool instance for all error cases
- Use ILogger for all logging - never
Console.WriteLineor similar - Log at appropriate levels: Debug, Information, Warning, Error
- Include relevant context in log messages (user input, operation details)
- Don't log sensitive information (passwords, tokens, PII)
- Avoid string interpolation
- GOOD:
Logger.LogInformation("Received message: {message}", message); - BAD:
Logger.LogInformation($"Received message: {message}"); - GOOD:
Logger.LogError(ex, "Error occurred"); - BAD:
Logger.LogError($"Error occurred, {ex.Message}");
- GOOD:
- Use IOutputHelper only for final CLI results in
HandleCommand()- not for progress or debugging, those useILogger. - Structure output for both CLI and JSON consumption
- Provide meaningful ToString() implementations for CLI output
- Use descriptive MCP method names (snake_case:
analyze_pipeline) - Provide clear descriptions for LLM agents
- Design parameters for LLM consumption - clear names and simple parameter types. Be wary of optional parameters that the LLM might eagerly come up with values for.
- Use consistent naming: Commands should follow existing patterns
- Use kebab-casing: CLI commands and options should use kebab-case (lowercase and hyphenated)
- Provide helpful descriptions: Both for CLI help and MCP discovery
- Design for both interfaces: Consider how commands work via CLI and MCP. In some cases it makes sense to differ implementations for CLI and MCP mode. A good rule of thumb is that all high level scenarios should be invokable from either context.
- Handle sub-commands properly: Use command hierarchy and proper routing
- Use async/await properly for I/O operations
- Respect cancellation tokens in long-running operations
- Dispose resources properly using
usingstatements or try/finally
- Correct namespace:
Azure.Sdk.Tools.Cli.Tools - File organization: Group related tools in sub-directories, but keep flat namespace
- Tool registration: Always add to
SharedOptions.ToolsList - Dependency patterns: Use constructor injection, avoid static dependencies
// Good: Proper error handling
[McpServerTool, Description("Processes data")]
public async Task<ProcessResponse> ProcessData(string input, CancellationToken ct = default)
{
try
{
logger.LogInformation("Processing data: {input}", input);
var result = await DoWork(input, ct);
return new ProcessResponse { Data = result };
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to process data: {input}", input);
SetFailure();
return new ProcessResponse
{
ResponseError = $"Failed to process data: {ex.Message}"
};
}
}
// Good: Shared logic between CLI and MCP
public override async Task HandleCommand(InvocationContext ctx, CancellationToken ct)
{
var input = ctx.ParseResult.GetValueForArgument(inputArg);
var result = await ProcessData(input, ct);
ctx.ExitCode = ExitCode;
output.Output(result);
}// Bad: No error handling
[McpServerTool]
public ProcessResponse ProcessData(string input)
{
var result = DoWork(input); // Can throw exceptions
return new ProcessResponse { Data = result };
}
// Bad: Calling other tools directly
public class BadTool : MCPTool
{
private readonly AnotherTool anotherTool;
public BadResponse DoWork()
{
return anotherTool.Process(); // Don't do this
}
}
// Bad: Wrong namespace
namespace Azure.Sdk.Tools.Cli.Tools.YourTool // Incorrect
{
public class YourTool : MCPTool { }
}
// Bad: Console output in tools
public void DoWork()
{
Console.WriteLine("Working..."); // Use ILogger instead
}
// Bad: Not calling SetFailure on errors
catch (Exception ex)
{
return new Response { ResponseError = ex.Message }; // Missing SetFailure()
}Refer to CLI command hierarchy for guidelines on CLI command structure.