Repository: Azure/azure-mcp (2025-07-17T23:29:38Z)

Issue:
Add azmcp storage datalake directory create command
Using /src/docs/new-command.md as a guide, implement the following command:

azmcp storage datalake directory create


Discussion:

Patch:
diff --git a/.vscode/cspell.json b/.vscode/cspell.json
index 9ebb70390..efad1ddfa 100644
--- a/.vscode/cspell.json
+++ b/.vscode/cspell.json
@@ -226,6 +226,7 @@
         "monitoredresources",
         "msal",
         "myaccount",
+        "myfilesystem",
         "mysvc",
         "mycluster",
         "MACOS",
diff --git a/src/Areas/Storage/Commands/DataLake/Directory/DirectoryCreateCommand.cs b/src/Areas/Storage/Commands/DataLake/Directory/DirectoryCreateCommand.cs
new file mode 100644
index 000000000..e19585bce
--- /dev/null
+++ b/src/Areas/Storage/Commands/DataLake/Directory/DirectoryCreateCommand.cs
@@ -0,0 +1,86 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using AzureMcp.Areas.Storage.Commands;
+using AzureMcp.Areas.Storage.Models;
+using AzureMcp.Areas.Storage.Options;
+using AzureMcp.Areas.Storage.Options.DataLake.Directory;
+using AzureMcp.Areas.Storage.Services;
+using AzureMcp.Commands.Storage;
+using AzureMcp.Services.Telemetry;
+using Microsoft.Extensions.Logging;
+
+namespace AzureMcp.Areas.Storage.Commands.DataLake.Directory;
+
+public sealed class DirectoryCreateCommand(ILogger<DirectoryCreateCommand> logger) : BaseStorageCommand<DirectoryCreateOptions>
+{
+    private const string CommandTitle = "Create Data Lake Directory";
+    private readonly ILogger<DirectoryCreateCommand> _logger = logger;
+
+    private readonly Option<string> _directoryPathOption = StorageOptionDefinitions.DirectoryPath;
+
+    public override string Name => "create";
+
+    public override string Description =>
+        """
+        Create a directory in a Data Lake file system. This command creates a new directory at the specified path
+        within the Data Lake file system. The directory path must include the file system name as the first component
+        (e.g., 'myfilesystem/data/logs' or 'myfilesystem/archives/2024'). The path supports nested structures using 
+        forward slashes (/). If the directory already exists, the operation will succeed and return the existing 
+        directory information. Returns directory metadata including name, type, and creation timestamp as JSON.
+        """;
+
+    public override string Title => CommandTitle;
+
+    protected override void RegisterOptions(Command command)
+    {
+        base.RegisterOptions(command);
+        command.AddOption(_directoryPathOption);
+    }
+
+    protected override DirectoryCreateOptions BindOptions(ParseResult parseResult)
+    {
+        var options = base.BindOptions(parseResult);
+        options.DirectoryPath = parseResult.GetValueForOption(_directoryPathOption);
+        return options;
+    }
+
+    [McpServerTool(Destructive = false, ReadOnly = false, Title = CommandTitle)]
+    public override async Task<CommandResponse> ExecuteAsync(CommandContext context, ParseResult parseResult)
+    {
+        var options = BindOptions(parseResult);
+
+        try
+        {
+            if (!Validate(parseResult.CommandResult, context.Response).IsValid)
+            {
+                return context.Response;
+            }
+
+            context.Activity?.WithSubscriptionTag(options);
+
+            var storageService = context.GetService<IStorageService>();
+
+            var directory = await storageService.CreateDirectory(
+                options.Account!,
+                options.DirectoryPath!,
+                options.Subscription!,
+                options.Tenant,
+                options.RetryPolicy);
+
+            context.Response.Results = ResponseResult.Create(
+                new DirectoryCreateCommandResult(directory),
+                StorageJsonContext.Default.DirectoryCreateCommandResult);
+        }
+        catch (Exception ex)
+        {
+            _logger.LogError(ex, "Error creating directory. Account: {Account}, DirectoryPath: {DirectoryPath}.",
+                options.Account, options.DirectoryPath);
+            HandleException(context, ex);
+        }
+
+        return context.Response;
+    }
+
+    internal record DirectoryCreateCommandResult(DataLakePathInfo Directory);
+}
diff --git a/src/Areas/Storage/Commands/StorageJsonContext.cs b/src/Areas/Storage/Commands/StorageJsonContext.cs
index 31df77bd4..58f0af663 100644
--- a/src/Areas/Storage/Commands/StorageJsonContext.cs
+++ b/src/Areas/Storage/Commands/StorageJsonContext.cs
@@ -5,6 +5,7 @@
 using AzureMcp.Areas.Storage.Commands.Account;
 using AzureMcp.Areas.Storage.Commands.Blob;
 using AzureMcp.Areas.Storage.Commands.Blob.Container;
+using AzureMcp.Areas.Storage.Commands.DataLake.Directory;
 using AzureMcp.Areas.Storage.Commands.DataLake.FileSystem;
 using AzureMcp.Areas.Storage.Commands.Table;
 
@@ -16,6 +17,7 @@ namespace AzureMcp.Commands.Storage;
 [JsonSerializable(typeof(ContainerListCommand.ContainerListCommandResult))]
 [JsonSerializable(typeof(ContainerDetailsCommand.ContainerDetailsCommandResult))]
 [JsonSerializable(typeof(FileSystemListPathsCommand.FileSystemListPathsCommandResult))]
+[JsonSerializable(typeof(DirectoryCreateCommand.DirectoryCreateCommandResult))]
 [JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
 internal sealed partial class StorageJsonContext : JsonSerializerContext
 {
diff --git a/src/Areas/Storage/Options/DataLake/Directory/DirectoryCreateOptions.cs b/src/Areas/Storage/Options/DataLake/Directory/DirectoryCreateOptions.cs
new file mode 100644
index 000000000..bb8d3de39
--- /dev/null
+++ b/src/Areas/Storage/Options/DataLake/Directory/DirectoryCreateOptions.cs
@@ -0,0 +1,13 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.Text.Json.Serialization;
+using AzureMcp.Areas.Storage.Options;
+
+namespace AzureMcp.Areas.Storage.Options.DataLake.Directory;
+
+public class DirectoryCreateOptions : BaseStorageOptions
+{
+    [JsonPropertyName(StorageOptionDefinitions.DirectoryPathName)]
+    public string? DirectoryPath { get; set; }
+}
diff --git a/src/Areas/Storage/Options/StorageOptionDefinitions.cs b/src/Areas/Storage/Options/StorageOptionDefinitions.cs
index e52c76cf4..1573bd3bd 100644
--- a/src/Areas/Storage/Options/StorageOptionDefinitions.cs
+++ b/src/Areas/Storage/Options/StorageOptionDefinitions.cs
@@ -9,6 +9,7 @@ public static class StorageOptionDefinitions
     public const string ContainerName = "container-name";
     public const string TableName = "table-name";
     public const string FileSystemName = "file-system-name";
+    public const string DirectoryPathName = "directory-path";
 
     public static readonly Option<string> Account = new(
         $"--{AccountName}",
@@ -41,4 +42,12 @@ public static class StorageOptionDefinitions
     {
         IsRequired = true
     };
+
+    public static readonly Option<string> DirectoryPath = new(
+        $"--{DirectoryPathName}",
+        "The full path of the directory to create in the Data Lake, including the file system name (e.g., 'myfilesystem/data/logs' or 'myfilesystem/archives/2024'). Use forward slashes (/) to separate the file system name from the directory path and for subdirectories."
+    )
+    {
+        IsRequired = true
+    };
 }
diff --git a/src/Areas/Storage/Services/IStorageService.cs b/src/Areas/Storage/Services/IStorageService.cs
index 60cc09375..85b55992e 100644
--- a/src/Areas/Storage/Services/IStorageService.cs
+++ b/src/Areas/Storage/Services/IStorageService.cs
@@ -31,4 +31,10 @@ Task<List<DataLakePathInfo>> ListDataLakePaths(
         string subscriptionId,
         string? tenant = null,
         RetryPolicyOptions? retryPolicy = null);
+    Task<DataLakePathInfo> CreateDirectory(
+        string accountName,
+        string directoryPath,
+        string subscriptionId,
+        string? tenant = null,
+        RetryPolicyOptions? retryPolicy = null);
 }
diff --git a/src/Areas/Storage/Services/StorageService.cs b/src/Areas/Storage/Services/StorageService.cs
index 46ce97d08..532a0f529 100644
--- a/src/Areas/Storage/Services/StorageService.cs
+++ b/src/Areas/Storage/Services/StorageService.cs
@@ -347,4 +347,59 @@ public async Task<List<DataLakePathInfo>> ListDataLakePaths(
 
         return paths;
     }
+
+    public async Task<DataLakePathInfo> CreateDirectory(
+        string accountName,
+        string directoryPath,
+        string subscriptionId,
+        string? tenant = null,
+        RetryPolicyOptions? retryPolicy = null)
+    {
+        ValidateRequiredParameters(accountName, directoryPath, subscriptionId);
+
+        var dataLakeServiceClient = await CreateDataLakeServiceClient(accountName, tenant, retryPolicy);
+
+        try
+        {
+            // Parse the directory path to extract file system name and directory path
+            var pathParts = directoryPath.Split('/', 2);
+            if (pathParts.Length < 2)
+            {
+                throw new ArgumentException("DirectoryPath must include file system name (e.g., 'myfilesystem/path/to/directory')");
+            }
+
+            var fileSystemName = pathParts[0];
+            var directoryPathWithinFileSystem = pathParts[1];
+
+            var fileSystemClient = dataLakeServiceClient.GetFileSystemClient(fileSystemName);
+            var directoryClient = fileSystemClient.GetDirectoryClient(directoryPathWithinFileSystem);
+            var response = await directoryClient.CreateIfNotExistsAsync();
+
+            if (response?.Value == null)
+            {
+                // Directory already exists, get its properties
+                var properties = await directoryClient.GetPropertiesAsync();
+                return new DataLakePathInfo(
+                    directoryPath,
+                    "directory",
+                    null, // Directories don't have content length
+                    properties.Value.LastModified,
+                    properties.Value.ETag.ToString());
+            }
+            else
+            {
+                // Directory was created, return new directory info
+                return new DataLakePathInfo(
+                    directoryPath,
+                    "directory",
+                    null, // Directories don't have content length
+                    response.Value.LastModified,
+                    response.Value.ETag.ToString());
+            }
+        }
+        catch (Exception ex)
+        {
+            throw new Exception($"Error creating directory: {ex.Message}", ex);
+        }
+    }
 }
diff --git a/src/Areas/Storage/StorageSetup.cs b/src/Areas/Storage/StorageSetup.cs
index e36f52fdc..15d632249 100644
--- a/src/Areas/Storage/StorageSetup.cs
+++ b/src/Areas/Storage/StorageSetup.cs
@@ -4,6 +4,7 @@
 using AzureMcp.Areas.Storage.Commands.Account;
 using AzureMcp.Areas.Storage.Commands.Blob;
 using AzureMcp.Areas.Storage.Commands.Blob.Container;
+using AzureMcp.Areas.Storage.Commands.DataLake.Directory;
 using AzureMcp.Areas.Storage.Commands.DataLake.FileSystem;
 using AzureMcp.Areas.Storage.Commands.Table;
 using AzureMcp.Areas.Storage.Services;
@@ -48,6 +49,10 @@ public void RegisterCommands(CommandGroup rootGroup, ILoggerFactory loggerFactor
         var fileSystem = new CommandGroup("file-system", "Data Lake file system operations - Commands for managing file systems and paths in Azure Data Lake Storage Gen2.");
         dataLake.AddSubGroup(fileSystem);
 
+        // Create directory subgroup under datalake
+        var directory = new CommandGroup("directory", "Data Lake directory operations - Commands for managing directories in Azure Data Lake Storage Gen2.");
+        dataLake.AddSubGroup(directory);
+
         // Register Storage commands
         storageAccount.AddCommand("list", new AccountListCommand(
             loggerFactory.CreateLogger<AccountListCommand>()));
@@ -63,5 +68,8 @@ public void RegisterCommands(CommandGroup rootGroup, ILoggerFactory loggerFactor
 
         fileSystem.AddCommand("list-paths", new FileSystemListPathsCommand(
             loggerFactory.CreateLogger<FileSystemListPathsCommand>()));
+
+        directory.AddCommand("create", new DirectoryCreateCommand(
+            loggerFactory.CreateLogger<DirectoryCreateCommand>()));
     }
 }


Test patch:
diff --git a/tests/Areas/Storage/LiveTests/StorageCommandTests.cs b/tests/Areas/Storage/LiveTests/StorageCommandTests.cs
index 87606b19c..eba21c993 100644
--- a/tests/Areas/Storage/LiveTests/StorageCommandTests.cs
+++ b/tests/Areas/Storage/LiveTests/StorageCommandTests.cs
@@ -225,5 +225,30 @@ public async Task Should_list_datalake_filesystem_paths()
             var actual = result.AssertProperty("paths");
             Assert.Equal(JsonValueKind.Array, actual.ValueKind);
         }
+
+        [Fact]
+        [Trait("Category", "Live")]
+        public async Task Should_create_datalake_directory()
+        {
+            var directoryPath = "testfilesystem/test-directory";
+
+            var result = await CallToolAsync(
+                "azmcp_storage_datalake_directory_create",
+                new()
+                {
+                    { "subscription", Settings.SubscriptionName },
+                    { "account-name", Settings.ResourceBaseName },
+                    { "directory-path", directoryPath }
+                });
+
+            var directory = result.AssertProperty("directory");
+            Assert.Equal(JsonValueKind.Object, directory.ValueKind);
+
+            var name = directory.GetProperty("name").GetString();
+            var type = directory.GetProperty("type").GetString();
+
+            Assert.Equal(directoryPath, name);
+            Assert.Equal("directory", type);
+        }
     }
 }
diff --git a/tests/Areas/Storage/UnitTests/DataLake/Directory/DirectoryCreateCommandTests.cs b/tests/Areas/Storage/UnitTests/DataLake/Directory/DirectoryCreateCommandTests.cs
new file mode 100644
index 000000000..a0ba0f4dc
--- /dev/null
+++ b/tests/Areas/Storage/UnitTests/DataLake/Directory/DirectoryCreateCommandTests.cs
@@ -0,0 +1,163 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.CommandLine.Parsing;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using AzureMcp.Areas.Storage.Commands.DataLake.Directory;
+using AzureMcp.Areas.Storage.Models;
+using AzureMcp.Areas.Storage.Services;
+using AzureMcp.Models.Command;
+using AzureMcp.Options;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using NSubstitute;
+using NSubstitute.ExceptionExtensions;
+using Xunit;
+
+namespace AzureMcp.Tests.Areas.Storage.UnitTests.DataLake.Directory;
+
+[Trait("Area", "Storage")]
+public class DirectoryCreateCommandTests
+{
+    private readonly IServiceProvider _serviceProvider;
+    private readonly IStorageService _storageService;
+    private readonly ILogger<DirectoryCreateCommand> _logger;
+    private readonly DirectoryCreateCommand _command;
+    private readonly CommandContext _context;
+    private readonly Parser _parser;
+    private readonly string _knownAccountName = "account123";
+    private readonly string _knownDirectoryPath = "filesystem123/data/logs";
+    private readonly string _knownSubscriptionId = "sub123";
+
+    public DirectoryCreateCommandTests()
+    {
+        _storageService = Substitute.For<IStorageService>();
+        _logger = Substitute.For<ILogger<DirectoryCreateCommand>>();
+
+        var collection = new ServiceCollection().AddSingleton(_storageService);
+
+        _serviceProvider = collection.BuildServiceProvider();
+        _command = new(_logger);
+        _context = new(_serviceProvider);
+        _parser = new(_command.GetCommand());
+    }
+
+    [Fact]
+    public async Task ExecuteAsync_WithValidParameters_CreatesDirectory()
+    {
+        // Arrange
+        var expectedDirectory = new DataLakePathInfo(
+            _knownDirectoryPath,
+            "directory",
+            null,
+            DateTimeOffset.Now,
+            "\"etag1\""
+        );
+
+        _storageService.CreateDirectory(
+            Arg.Is(_knownAccountName),
+            Arg.Is(_knownDirectoryPath),
+            Arg.Is(_knownSubscriptionId),
+            Arg.Any<string>(),
+            Arg.Any<RetryPolicyOptions>()).Returns(expectedDirectory);
+
+        var args = _parser.Parse([
+            "--account-name", _knownAccountName,
+            "--directory-path", _knownDirectoryPath,
+            "--subscription", _knownSubscriptionId
+        ]);
+
+        // Act
+        var response = await _command.ExecuteAsync(_context, args);
+
+        // Assert
+        Assert.NotNull(response);
+        Assert.Equal(200, response.Status);
+        Assert.NotNull(response.Results);
+
+        var json = JsonSerializer.Serialize(response.Results);
+        var result = JsonSerializer.Deserialize<DirectoryCreateResult>(json);
+
+        Assert.NotNull(result);
+        Assert.Equal(expectedDirectory.Name, result.Directory.Name);
+        Assert.Equal(expectedDirectory.Type, result.Directory.Type);
+        Assert.Equal("directory", result.Directory.Type);
+    }
+
+    [Fact]
+    public async Task ExecuteAsync_HandlesException()
+    {
+        // Arrange
+        var expectedError = "Test error";
+
+        _storageService.CreateDirectory(
+            Arg.Is(_knownAccountName),
+            Arg.Is(_knownDirectoryPath),
+            Arg.Is(_knownSubscriptionId),
+            null,
+            Arg.Any<RetryPolicyOptions>()).ThrowsAsync(new Exception(expectedError));
+
+        var args = _parser.Parse([
+            "--account-name", _knownAccountName,
+            "--directory-path", _knownDirectoryPath,
+            "--subscription", _knownSubscriptionId
+        ]);
+
+        // Act
+        var response = await _command.ExecuteAsync(_context, args);
+
+        // Assert
+        Assert.NotNull(response);
+        Assert.Equal(500, response.Status);
+        Assert.StartsWith(expectedError, response.Message);
+    }
+
+    [Theory]
+    [InlineData("--directory-path filesystem123/data/logs --subscription sub123", false)] // Missing account
+    [InlineData("--account-name account123 --subscription sub123", false)] // Missing directory-path
+    [InlineData("--account-name account123 --directory-path filesystem123/data/logs", false)] // Missing subscription
+    [InlineData("--account-name account123 --directory-path filesystem123/data/logs --subscription sub123", true)] // Valid
+    public async Task ExecuteAsync_ValidatesRequiredParameters(string args, bool shouldSucceed)
+    {
+        // Arrange
+        if (shouldSucceed)
+        {
+            var expectedDirectory = new DataLakePathInfo("filesystem123/data/logs", "directory", null, DateTimeOffset.Now, "\"etag1\"");
+            _storageService.CreateDirectory(
+                Arg.Any<string>(),
+                Arg.Any<string>(),
+                Arg.Any<string>(),
+                Arg.Any<string>(),
+                Arg.Any<RetryPolicyOptions>()).Returns(expectedDirectory);
+        }
+
+        var parseResult = _parser.Parse(args.Split(' '));
+
+        // Act
+        var response = await _command.ExecuteAsync(_context, parseResult);
+
+        // Assert
+        Assert.Equal(shouldSucceed ? 200 : 400, response.Status);
+        if (!shouldSucceed)
+        {
+            Assert.Contains("required", response.Message.ToLower());
+        }
+    }
+
+    [Fact]
+    public void Constructor_InitializesCommandCorrectly()
+    {
+        var command = _command.GetCommand();
+        Assert.Equal("create", command.Name);
+        Assert.NotNull(command.Description);
+        Assert.NotEmpty(command.Description);
+        Assert.Contains("Create a directory", command.Description);
+    }
+
+    private class DirectoryCreateResult
+    {
+        [JsonPropertyName("directory")]
+        public DataLakePathInfo Directory { get; set; } = null!;
+    }
+}