Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using System.CommandLine;
using System.Net;
using Azure.Mcp.Core.Commands;
using Azure.Mcp.Core.Commands.Subscription;
using Azure.Mcp.Core.Extensions;
using Azure.Mcp.Core.Models.Command;
using Azure.Mcp.Core.Models.Option;
using Azure.Mcp.Core.Options;
using Azure.Mcp.Tools.IoTHub.Options;
using Azure.Mcp.Tools.IoTHub.Options.Device;
Comment on lines +9 to +13
using Azure.Mcp.Tools.IoTHub.Services;
using Microsoft.Extensions.Logging;
using Microsoft.Mcp.Core.Models.Option;

namespace Azure.Mcp.Tools.IoTHub.Commands.Device;

public sealed class IoTHubDeviceListCommand(IIoTHubDeviceService service, ILogger<IoTHubDeviceListCommand> logger)
: BaseIoTHubCommand<IoTHubDeviceListOptions>
{
Comment on lines +20 to +22
public override string Id => "iothub-device-list";
public override string Name => "list";
public override string Description => "List devices in an IoT Hub device registry. Returns each device identity metadata without authentication keys. " +
"This is the preferred command for listing devices, including when the user asks for a small, specific number of devices (e.g. 'list 2 devices'); set --max-count to the requested number. " +
"Use --max-count to set the page size (default 100, maximum 100). Values greater than 100 are capped at 100, so one call returns at most 100 devices. " +
"For natural-language user requests to list all devices, the rest of the devices, more than 100 devices, or any large device set, use the 'iothub query run' command (MCP tool iothub_query_run) with a compact projection instead of this list command, but still return only one page and do not loop for additional pages. " +
"For normal list/show responses, present a compact summary with deviceId, status, connectionState, and any user-requested tags or fields. Do not include full raw JSON unless the user explicitly asks for raw device identity metadata. " +
"Hub names/IDs are case-sensitive and must match exactly.";
public override string Title => "List IoT Hub Devices";
public override ToolMetadata Metadata => new() { Destructive = false, Idempotent = true, OpenWorld = false, ReadOnly = true, LocalRequired = false, Secret = false };

private const int DefaultMaxCount = 100;
private const int MinMaxCount = 1;
private const int MaxMaxCount = DefaultMaxCount;

private readonly IIoTHubDeviceService _service = service ?? throw new ArgumentNullException(nameof(service));
private readonly ILogger<IoTHubDeviceListCommand> _logger = logger ?? throw new ArgumentNullException(nameof(logger));

protected override void RegisterOptions(Command command)
{
base.RegisterOptions(command);
command.Options.Add(IoTHubOptionDefinitions.Name.AsRequired());
command.Options.Add(IoTHubOptionDefinitions.MaxCount.AsOptional());
}

protected override IoTHubDeviceListOptions BindOptions(ParseResult parseResult)
{
var options = base.BindOptions(parseResult);
options.Name = parseResult.GetValueOrDefault<string>(IoTHubOptionDefinitions.Name.Name);
var maxCount = parseResult.GetValueOrDefault<int?>(IoTHubOptionDefinitions.MaxCount.Name);
options.MaxCount = maxCount switch
{
null => DefaultMaxCount,
> MaxMaxCount => MaxMaxCount,
_ => maxCount.Value
};
return options;
}

public override async Task<CommandResponse> ExecuteAsync(CommandContext context, ParseResult parseResult, CancellationToken cancellationToken)
{
if (!Validate(parseResult.CommandResult, context.Response).IsValid)
{
return context.Response;
}

var options = BindOptions(parseResult);

if (options.MaxCount < MinMaxCount)
{
context.Response.Status = HttpStatusCode.BadRequest;
context.Response.Message = $"The entered max-count '{options.MaxCount}' is less than 1 device. Please specify a value of at least 1.";
return context.Response;
}

try
{
var result = await _service.ListDevices(
options.Name!,
options.ResourceGroup!,
options.Subscription!,
options.MaxCount,
options.RetryPolicy,
cancellationToken);

context.Response.Results = ResponseResult.Create(result, IoTHubJsonContext.Default.DeviceListResult);

if (result.Truncated)
{
context.Response.Message = $"Showing the first {options.MaxCount} devices. The hub contains more devices, but the results were truncated because the maximum is {options.MaxCount}.";
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error listing devices in IoT Hub");
HandleException(context, ex);
}

return context.Response;
}

protected override HttpStatusCode GetStatusCode(Exception ex) => ex switch
{
TimeoutException => HttpStatusCode.RequestTimeout,
_ => base.GetStatusCode(ex)
};
}
10 changes: 10 additions & 0 deletions tools/Azure.Mcp.Tools.IoTHub/src/Models/DeviceListResult.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using System.Text.Json.Serialization;

namespace Azure.Mcp.Tools.IoTHub.Models;

public record DeviceListResult(
[property: JsonPropertyName("devices")] List<DeviceIdentity> Devices,
[property: JsonPropertyName("truncated")] bool Truncated);
Comment on lines +8 to +10
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using Azure.Mcp.Core.Options;

namespace Azure.Mcp.Tools.IoTHub.Options.Device;

public class IoTHubDeviceListOptions : SubscriptionOptions
Comment on lines +4 to +8
{
public string? Name { get; set; }
public int? MaxCount { get; set; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using System.CommandLine;
using Azure.Mcp.Core.Models.Command;
using Azure.Mcp.Tools.IoTHub.Commands.Device;
using Azure.Mcp.Tools.IoTHub.Models;
using Azure.Mcp.Tools.IoTHub.Services;
Comment on lines +4 to +8
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Xunit;

namespace Azure.Mcp.Tools.IoTHub.UnitTests.Device;

public class IoTHubDeviceListCommandTests
{
private readonly IServiceProvider _serviceProvider;
private readonly IIoTHubDeviceService _service;
private readonly ILogger<IoTHubDeviceListCommand> _logger;
private readonly IoTHubDeviceListCommand _command;
private readonly CommandContext _context;
private readonly Command _commandDefinition;

public IoTHubDeviceListCommandTests()
{
_service = Substitute.For<IIoTHubDeviceService>();
_logger = Substitute.For<ILogger<IoTHubDeviceListCommand>>();

var collection = new ServiceCollection().AddSingleton(_service);

_serviceProvider = collection.BuildServiceProvider();
_command = new IoTHubDeviceListCommand(_service, _logger);
_context = new CommandContext(_serviceProvider);
_commandDefinition = _command.GetCommand();
}

[Fact]
public async Task ExecuteAsync_ListDevices_Success()
{
// Arrange
var name = "test-hub";
var resourceGroup = "test-rg";
var subscription = "sub-id";

var expectedDevices = new List<DeviceIdentity>
{
new DeviceIdentity("device1", "gen1", "aaaa==", "Connected", "Enabled", null, "2024-01-01T00:00:00Z", "2024-01-02T00:00:00Z", "2024-01-03T00:00:00Z", 0, new DeviceAuthentication("SAS"), null),
new DeviceIdentity("device2", "gen2", "bbbb==", "Connected", "Enabled", null, "2024-01-01T00:00:00Z", "2024-01-02T00:00:00Z", "2024-01-03T00:00:00Z", 0, new DeviceAuthentication("SAS"), null)
};

_service.ListDevices(
name,
resourceGroup,
subscription,
Arg.Any<int?>(),
Arg.Any<Core.Options.RetryPolicyOptions>(),
Arg.Any<CancellationToken>())
.Returns(new DeviceListResult(expectedDevices, false));

var args = _commandDefinition.Parse([
"--name", name,
"--resource-group", resourceGroup,
"--subscription", subscription
]);

// Act
var response = await _command.ExecuteAsync(_context, args, CancellationToken.None);

// Assert
Assert.NotNull(response);
Assert.NotNull(response.Results);
}

[Fact]
public async Task ExecuteAsync_ListDevices_WithMaxCount_Success()
{
// Arrange
var name = "test-hub";
var resourceGroup = "test-rg";
var subscription = "sub-id";
var maxCount = 10;

var expectedDevices = new List<DeviceIdentity>
{
new DeviceIdentity("device1", "gen1", "aaaa==", "Connected", "Enabled", null, "2024-01-01T00:00:00Z", "2024-01-02T00:00:00Z", "2024-01-03T00:00:00Z", 0, new DeviceAuthentication("SAS"), null)
};

_service.ListDevices(
name,
resourceGroup,
subscription,
maxCount,
Arg.Any<Core.Options.RetryPolicyOptions>(),
Arg.Any<CancellationToken>())
.Returns(new DeviceListResult(expectedDevices, false));

var args = _commandDefinition.Parse([
"--name", name,
"--resource-group", resourceGroup,
"--subscription", subscription,
"--max-count", maxCount.ToString()
]);

// Act
var response = await _command.ExecuteAsync(_context, args, CancellationToken.None);

// Assert
Assert.NotNull(response);
Assert.NotNull(response.Results);
}

[Fact]
public async Task ExecuteAsync_MaxCountGreaterThanOneHundred_CapsAtOneHundred()
{
// Arrange
var name = "test-hub";
var resourceGroup = "test-rg";
var subscription = "sub-id";

_service.ListDevices(
name,
resourceGroup,
subscription,
100,
Arg.Any<Core.Options.RetryPolicyOptions>(),
Arg.Any<CancellationToken>())
.Returns(new DeviceListResult([], false));

var args = _commandDefinition.Parse([
"--name", name,
"--resource-group", resourceGroup,
"--subscription", subscription,
"--max-count", "500"
]);

// Act
await _command.ExecuteAsync(_context, args, CancellationToken.None);

// Assert
await _service.Received(1).ListDevices(
name,
resourceGroup,
subscription,
100,
Arg.Any<Core.Options.RetryPolicyOptions>(),
Arg.Any<CancellationToken>());
}

[Fact]
public async Task ExecuteAsync_ListDevices_Truncated_SetsMessage()
{
// Arrange
var name = "test-hub";
var resourceGroup = "test-rg";
var subscription = "sub-id";

var pageDevices = new List<DeviceIdentity>
{
new DeviceIdentity("device1", "gen1", "aaaa==", "Connected", "Enabled", null, null, null, null, 0, new DeviceAuthentication("SAS"), null)
};

_service.ListDevices(
name,
resourceGroup,
subscription,
Arg.Any<int?>(),
Arg.Any<Core.Options.RetryPolicyOptions>(),
Arg.Any<CancellationToken>())
.Returns(new DeviceListResult(pageDevices, true));

var args = _commandDefinition.Parse([
"--name", name,
"--resource-group", resourceGroup,
"--subscription", subscription
]);

// Act
var response = await _command.ExecuteAsync(_context, args, CancellationToken.None);

// Assert
Assert.NotNull(response.Results);
Assert.NotNull(response.Message);
Assert.Contains("truncated", response.Message, StringComparison.OrdinalIgnoreCase);
}

[Fact]
public async Task ExecuteAsync_MaxCountLessThanOne_ReturnsBadRequest()
{
// Arrange
var name = "test-hub";
var resourceGroup = "test-rg";
var subscription = "sub-id";

var args = _commandDefinition.Parse([
"--name", name,
"--resource-group", resourceGroup,
"--subscription", subscription,
"--max-count", "0"
]);

// Act
var response = await _command.ExecuteAsync(_context, args, CancellationToken.None);

// Assert
Assert.Equal(System.Net.HttpStatusCode.BadRequest, response.Status);
Assert.Null(response.Results);
Assert.Contains("less than 1", response.Message, StringComparison.OrdinalIgnoreCase);
}

[Fact]
public void Constructor_InitializesCommandCorrectly()
{
// Assert
Assert.Equal("iothub-device-list", _command.Id);
Assert.Equal("list", _command.Name);
Assert.NotNull(_command.Description);
Assert.Contains("more than 100 devices", _command.Description, StringComparison.Ordinal);
Assert.Contains("iothub query run", _command.Description, StringComparison.Ordinal);
Assert.Contains("iothub_query_run", _command.Description, StringComparison.Ordinal);
Assert.Contains("compact projection", _command.Description, StringComparison.Ordinal);
Assert.Contains("compact summary", _command.Description, StringComparison.Ordinal);
Assert.Contains("return only one page", _command.Description, StringComparison.Ordinal);
Assert.Contains("do not loop", _command.Description, StringComparison.Ordinal);
Assert.True(_command.Metadata.ReadOnly);
Assert.False(_command.Metadata.Secret);
}
}
Loading