.NET: Fix Error 404 Agent Hosted MCP (#3678)

* Initial plan

* Fix issue #3195: Handle empty Version and ID in Azure AI agent responses

This fix addresses the issue where hosted MCP agents (like AgentWithHostedMCP)
fail with "ID cannot be null or empty (Parameter 'id')" error when deployed
to Azure AI Foundry.

Changes:
- Add CreateAgentReference helper method in AzureAIProjectChatClient that defaults
  empty version to "latest"
- Update CreateChatClientAgentOptions to generate a fallback ID from name and version
  when AgentVersion.Id is null or empty
- Add GetAgentVersionResponseJsonWithEmptyVersion and GetAgentResponseJsonWithEmptyVersion
  test data methods
- Add unit tests for empty version handling scenarios

Co-authored-by: rogerbarreto <19890735+rogerbarreto@users.noreply.github.com>

* Address code review feedback: improve documentation and test comments

Co-authored-by: rogerbarreto <19890735+rogerbarreto@users.noreply.github.com>

* Address PR review: Use IsNullOrWhiteSpace and add whitespace unit tests

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
This commit is contained in:
Roger Barreto
2026-02-05 13:39:59 +00:00
committed by GitHub
Unverified
parent aa88195dcd
commit 2b66ca03b2
4 changed files with 308 additions and 13 deletions
@@ -64,13 +64,27 @@ internal sealed class AzureAIProjectChatClient : DelegatingChatClient
internal AzureAIProjectChatClient(AIProjectClient aiProjectClient, AgentVersion agentVersion, ChatOptions? chatOptions)
: this(
aiProjectClient,
new AgentReference(Throw.IfNull(agentVersion).Name, agentVersion.Version),
CreateAgentReference(Throw.IfNull(agentVersion)),
(agentVersion.Definition as PromptAgentDefinition)?.Model,
chatOptions)
{
this._agentVersion = agentVersion;
}
/// <summary>
/// Creates an <see cref="AgentReference"/> from an <see cref="AgentVersion"/>.
/// Uses the agent version's version if available, otherwise defaults to "latest".
/// </summary>
/// <param name="agentVersion">The agent version to create a reference from.</param>
/// <returns>An <see cref="AgentReference"/> for the specified agent version.</returns>
private static AgentReference CreateAgentReference(AgentVersion agentVersion)
{
// If the version is null, empty, or whitespace, use "latest" as the default.
// This handles cases where hosted agents (like MCP agents) may not have a version assigned.
var version = string.IsNullOrWhiteSpace(agentVersion.Version) ? "latest" : agentVersion.Version;
return new AgentReference(agentVersion.Name, version);
}
/// <inheritdoc/>
public override object? GetService(Type serviceType, object? serviceKey = null)
{
@@ -543,9 +543,16 @@ public static partial class AzureAIProjectChatClientExtensions
}
}
// Use the agent version's ID if available, otherwise generate one from name and version.
// This handles cases where hosted agents (like MCP agents) may not have an ID assigned.
var version = string.IsNullOrWhiteSpace(agentVersion.Version) ? "latest" : agentVersion.Version;
var agentId = string.IsNullOrWhiteSpace(agentVersion.Id)
? $"{agentVersion.Name}:{version}"
: agentVersion.Id;
var agentOptions = new ChatClientAgentOptions()
{
Id = agentVersion.Id,
Id = agentId,
Name = agentVersion.Name,
Description = agentVersion.Description,
};
@@ -2384,6 +2384,134 @@ public sealed class AzureAIProjectChatClientExtensionsTests
#endregion
#region Empty Version and ID Handling Tests
/// <summary>
/// Verify that GetAIAgentAsync handles an agent with empty version by using "latest" as fallback.
/// </summary>
[Fact]
public async Task GetAIAgentAsync_WithEmptyVersion_CreatesAgentSuccessfullyAsync()
{
// Arrange
AIProjectClient client = this.CreateTestAgentClientWithEmptyVersion();
var options = new ChatClientAgentOptions
{
Name = "test-agent",
ChatOptions = new ChatOptions { Instructions = "Test" }
};
// Act
ChatClientAgent agent = await client.GetAIAgentAsync(options);
// Assert
Assert.NotNull(agent);
Assert.IsType<ChatClientAgent>(agent);
// Verify the agent ID is generated from server-returned name ("agent_abc123") and "latest"
Assert.Equal("agent_abc123:latest", agent.Id);
}
/// <summary>
/// Verify that AsAIAgent with AgentRecord handles empty version by using "latest" as fallback.
/// </summary>
[Fact]
public void AsAIAgent_WithAgentRecordEmptyVersion_CreatesAgentWithGeneratedId()
{
// Arrange
AIProjectClient client = this.CreateTestAgentClientWithEmptyVersion();
AgentRecord agentRecord = this.CreateTestAgentRecordWithEmptyVersion();
// Act
var agent = client.AsAIAgent(agentRecord);
// Assert
Assert.NotNull(agent);
// Verify the agent ID is generated from agent record name ("agent_abc123") and "latest"
Assert.Equal("agent_abc123:latest", agent.Id);
}
/// <summary>
/// Verify that AsAIAgent with AgentVersion handles empty version by using "latest" as fallback.
/// </summary>
[Fact]
public void AsAIAgent_WithAgentVersionEmptyVersion_CreatesAgentWithGeneratedId()
{
// Arrange
AIProjectClient client = this.CreateTestAgentClientWithEmptyVersion();
AgentVersion agentVersion = this.CreateTestAgentVersionWithEmptyVersion();
// Act
var agent = client.AsAIAgent(agentVersion);
// Assert
Assert.NotNull(agent);
// Verify the agent ID is generated from agent version name ("agent_abc123") and "latest"
Assert.Equal("agent_abc123:latest", agent.Id);
}
/// <summary>
/// Verify that GetAIAgentAsync handles an agent with whitespace-only version by using "latest" as fallback.
/// </summary>
[Fact]
public async Task GetAIAgentAsync_WithWhitespaceVersion_CreatesAgentSuccessfullyAsync()
{
// Arrange
AIProjectClient client = this.CreateTestAgentClientWithWhitespaceVersion();
var options = new ChatClientAgentOptions
{
Name = "test-agent",
ChatOptions = new ChatOptions { Instructions = "Test" }
};
// Act
ChatClientAgent agent = await client.GetAIAgentAsync(options);
// Assert
Assert.NotNull(agent);
Assert.IsType<ChatClientAgent>(agent);
// Verify the agent ID is generated from server-returned name ("agent_abc123") and "latest"
Assert.Equal("agent_abc123:latest", agent.Id);
}
/// <summary>
/// Verify that AsAIAgent with AgentRecord handles whitespace-only version by using "latest" as fallback.
/// </summary>
[Fact]
public void AsAIAgent_WithAgentRecordWhitespaceVersion_CreatesAgentWithGeneratedId()
{
// Arrange
AIProjectClient client = this.CreateTestAgentClientWithWhitespaceVersion();
AgentRecord agentRecord = this.CreateTestAgentRecordWithWhitespaceVersion();
// Act
var agent = client.AsAIAgent(agentRecord);
// Assert
Assert.NotNull(agent);
// Verify the agent ID is generated from agent record name ("agent_abc123") and "latest"
Assert.Equal("agent_abc123:latest", agent.Id);
}
/// <summary>
/// Verify that AsAIAgent with AgentVersion handles whitespace-only version by using "latest" as fallback.
/// </summary>
[Fact]
public void AsAIAgent_WithAgentVersionWhitespaceVersion_CreatesAgentWithGeneratedId()
{
// Arrange
AIProjectClient client = this.CreateTestAgentClientWithWhitespaceVersion();
AgentVersion agentVersion = this.CreateTestAgentVersionWithWhitespaceVersion();
// Act
var agent = client.AsAIAgent(agentVersion);
// Assert
Assert.NotNull(agent);
// Verify the agent ID is generated from agent version name ("agent_abc123") and "latest"
Assert.Equal("agent_abc123:latest", agent.Id);
}
#endregion
#region ApplyToolsToAgentDefinition Tests
/// <summary>
@@ -2678,6 +2806,54 @@ public sealed class AzureAIProjectChatClientExtensionsTests
return ModelReaderWriter.Read<AgentRecord>(BinaryData.FromString(TestDataUtil.GetAgentResponseJson(agentDefinition: agentDefinition)))!;
}
/// <summary>
/// Creates a test AIProjectClient with empty version fields for testing hosted MCP agents.
/// </summary>
private FakeAgentClient CreateTestAgentClientWithEmptyVersion(string? agentName = null, string? instructions = null, string? description = null, AgentDefinition? agentDefinitionResponse = null)
{
return new FakeAgentClient(agentName, instructions, description, agentDefinitionResponse, useEmptyVersion: true);
}
/// <summary>
/// Creates a test AgentRecord with empty version for testing hosted MCP agents.
/// </summary>
private AgentRecord CreateTestAgentRecordWithEmptyVersion(AgentDefinition? agentDefinition = null)
{
return ModelReaderWriter.Read<AgentRecord>(BinaryData.FromString(TestDataUtil.GetAgentResponseJsonWithEmptyVersion(agentDefinition: agentDefinition)))!;
}
/// <summary>
/// Creates a test AgentVersion with empty version for testing hosted MCP agents.
/// </summary>
private AgentVersion CreateTestAgentVersionWithEmptyVersion()
{
return ModelReaderWriter.Read<AgentVersion>(BinaryData.FromString(TestDataUtil.GetAgentVersionResponseJsonWithEmptyVersion()))!;
}
/// <summary>
/// Creates a test AIProjectClient with whitespace-only version fields for testing hosted MCP agents.
/// </summary>
private FakeAgentClient CreateTestAgentClientWithWhitespaceVersion(string? agentName = null, string? instructions = null, string? description = null, AgentDefinition? agentDefinitionResponse = null)
{
return new FakeAgentClient(agentName, instructions, description, agentDefinitionResponse, versionMode: VersionMode.Whitespace);
}
/// <summary>
/// Creates a test AgentRecord with whitespace-only version for testing hosted MCP agents.
/// </summary>
private AgentRecord CreateTestAgentRecordWithWhitespaceVersion(AgentDefinition? agentDefinition = null)
{
return ModelReaderWriter.Read<AgentRecord>(BinaryData.FromString(TestDataUtil.GetAgentResponseJsonWithWhitespaceVersion(agentDefinition: agentDefinition)))!;
}
/// <summary>
/// Creates a test AgentVersion with whitespace-only version for testing hosted MCP agents.
/// </summary>
private AgentVersion CreateTestAgentVersionWithWhitespaceVersion()
{
return ModelReaderWriter.Read<AgentVersion>(BinaryData.FromString(TestDataUtil.GetAgentVersionResponseJsonWithWhitespaceVersion()))!;
}
private const string OpenAPISpec = """
{
"openapi": "3.0.3",
@@ -2716,14 +2892,26 @@ public sealed class AzureAIProjectChatClientExtensionsTests
return ModelReaderWriter.Read<AgentVersion>(BinaryData.FromString(TestDataUtil.GetAgentVersionResponseJson()))!;
}
/// <summary>
/// Specifies the version mode for test data generation.
/// </summary>
private enum VersionMode
{
Normal,
Empty,
Whitespace
}
/// <summary>
/// Fake AIProjectClient for testing.
/// </summary>
private sealed class FakeAgentClient : AIProjectClient
{
public FakeAgentClient(string? agentName = null, string? instructions = null, string? description = null, AgentDefinition? agentDefinitionResponse = null)
public FakeAgentClient(string? agentName = null, string? instructions = null, string? description = null, AgentDefinition? agentDefinitionResponse = null, bool useEmptyVersion = false, VersionMode versionMode = VersionMode.Normal)
{
this.Agents = new FakeAIProjectAgentsOperations(agentName, instructions, description, agentDefinitionResponse);
// Handle backward compatibility with bool parameter
var effectiveVersionMode = useEmptyVersion ? VersionMode.Empty : versionMode;
this.Agents = new FakeAIProjectAgentsOperations(agentName, instructions, description, agentDefinitionResponse, effectiveVersionMode);
}
public override ClientConnection GetConnection(string connectionId)
@@ -2739,60 +2927,82 @@ public sealed class AzureAIProjectChatClientExtensionsTests
private readonly string? _instructions;
private readonly string? _description;
private readonly AgentDefinition? _agentDefinition;
private readonly VersionMode _versionMode;
public FakeAIProjectAgentsOperations(string? agentName = null, string? instructions = null, string? description = null, AgentDefinition? agentDefinitionResponse = null)
public FakeAIProjectAgentsOperations(string? agentName = null, string? instructions = null, string? description = null, AgentDefinition? agentDefinitionResponse = null, VersionMode versionMode = VersionMode.Normal)
{
this._agentName = agentName;
this._instructions = instructions;
this._description = description;
this._agentDefinition = agentDefinitionResponse;
this._versionMode = versionMode;
}
private string GetAgentResponseJson()
{
return this._versionMode switch
{
VersionMode.Empty => TestDataUtil.GetAgentResponseJsonWithEmptyVersion(this._agentName, this._agentDefinition, this._instructions, this._description),
VersionMode.Whitespace => TestDataUtil.GetAgentResponseJsonWithWhitespaceVersion(this._agentName, this._agentDefinition, this._instructions, this._description),
_ => TestDataUtil.GetAgentResponseJson(this._agentName, this._agentDefinition, this._instructions, this._description)
};
}
private string GetAgentVersionResponseJson()
{
return this._versionMode switch
{
VersionMode.Empty => TestDataUtil.GetAgentVersionResponseJsonWithEmptyVersion(this._agentName, this._agentDefinition, this._instructions, this._description),
VersionMode.Whitespace => TestDataUtil.GetAgentVersionResponseJsonWithWhitespaceVersion(this._agentName, this._agentDefinition, this._instructions, this._description),
_ => TestDataUtil.GetAgentVersionResponseJson(this._agentName, this._agentDefinition, this._instructions, this._description)
};
}
public override ClientResult GetAgent(string agentName, RequestOptions options)
{
var responseJson = TestDataUtil.GetAgentResponseJson(this._agentName, this._agentDefinition, this._instructions, this._description);
var responseJson = this.GetAgentResponseJson();
return ClientResult.FromValue(ModelReaderWriter.Read<AgentRecord>(BinaryData.FromString(responseJson))!, new MockPipelineResponse(200, BinaryData.FromString(responseJson)));
}
public override ClientResult<AgentRecord> GetAgent(string agentName, CancellationToken cancellationToken = default)
{
var responseJson = TestDataUtil.GetAgentResponseJson(this._agentName, this._agentDefinition, this._instructions, this._description);
var responseJson = this.GetAgentResponseJson();
return ClientResult.FromValue(ModelReaderWriter.Read<AgentRecord>(BinaryData.FromString(responseJson))!, new MockPipelineResponse(200));
}
public override Task<ClientResult> GetAgentAsync(string agentName, RequestOptions options)
{
var responseJson = TestDataUtil.GetAgentResponseJson(this._agentName, this._agentDefinition, this._instructions, this._description);
var responseJson = this.GetAgentResponseJson();
return Task.FromResult<ClientResult>(ClientResult.FromValue(ModelReaderWriter.Read<AgentRecord>(BinaryData.FromString(responseJson))!, new MockPipelineResponse(200, BinaryData.FromString(responseJson))));
}
public override Task<ClientResult<AgentRecord>> GetAgentAsync(string agentName, CancellationToken cancellationToken = default)
{
var responseJson = TestDataUtil.GetAgentResponseJson(this._agentName, this._agentDefinition, this._instructions, this._description);
var responseJson = this.GetAgentResponseJson();
return Task.FromResult(ClientResult.FromValue(ModelReaderWriter.Read<AgentRecord>(BinaryData.FromString(responseJson))!, new MockPipelineResponse(200)));
}
public override ClientResult CreateAgentVersion(string agentName, BinaryContent content, RequestOptions? options = null)
{
var responseJson = TestDataUtil.GetAgentVersionResponseJson(this._agentName, this._agentDefinition, this._instructions, this._description);
var responseJson = this.GetAgentVersionResponseJson();
return ClientResult.FromValue(ModelReaderWriter.Read<AgentVersion>(BinaryData.FromString(responseJson))!, new MockPipelineResponse(200, BinaryData.FromString(responseJson)));
}
public override ClientResult<AgentVersion> CreateAgentVersion(string agentName, AgentVersionCreationOptions? options = null, CancellationToken cancellationToken = default)
{
var responseJson = TestDataUtil.GetAgentVersionResponseJson(this._agentName, this._agentDefinition, this._instructions, this._description);
var responseJson = this.GetAgentVersionResponseJson();
return ClientResult.FromValue(ModelReaderWriter.Read<AgentVersion>(BinaryData.FromString(responseJson))!, new MockPipelineResponse(200));
}
public override Task<ClientResult> CreateAgentVersionAsync(string agentName, BinaryContent content, RequestOptions? options = null)
{
var responseJson = TestDataUtil.GetAgentVersionResponseJson(this._agentName, this._agentDefinition, this._instructions, this._description);
var responseJson = this.GetAgentVersionResponseJson();
return Task.FromResult<ClientResult>(ClientResult.FromValue(ModelReaderWriter.Read<AgentVersion>(BinaryData.FromString(responseJson))!, new MockPipelineResponse(200, BinaryData.FromString(responseJson))));
}
public override Task<ClientResult<AgentVersion>> CreateAgentVersionAsync(string agentName, AgentVersionCreationOptions? options = null, CancellationToken cancellationToken = default)
{
var responseJson = TestDataUtil.GetAgentVersionResponseJson(this._agentName, this._agentDefinition, this._instructions, this._description);
var responseJson = this.GetAgentVersionResponseJson();
return Task.FromResult(ClientResult.FromValue(ModelReaderWriter.Read<AgentVersion>(BinaryData.FromString(responseJson))!, new MockPipelineResponse(200)));
}
}
@@ -52,6 +52,70 @@ internal static class TestDataUtil
return json;
}
/// <summary>
/// Gets the agent version response JSON with empty version and ID fields for testing hosted agents like MCP agents.
/// </summary>
public static string GetAgentVersionResponseJsonWithEmptyVersion(string? agentName = null, AgentDefinition? agentDefinition = null, string? instructions = null, string? description = null)
{
var json = s_agentVersionResponseJson;
json = ApplyAgentName(json, agentName);
json = ApplyAgentDefinition(json, agentDefinition);
json = ApplyInstructions(json, instructions);
json = ApplyDescription(json, description);
// Remove the version and id fields to simulate hosted agents without version
json = json.Replace("\"version\": \"1\",", "\"version\": \"\",");
json = json.Replace("\"id\": \"agent_abc123:1\",", "\"id\": \"\",");
return json;
}
/// <summary>
/// Gets the agent response JSON with empty version and ID fields in the latest version for testing hosted agents like MCP agents.
/// </summary>
public static string GetAgentResponseJsonWithEmptyVersion(string? agentName = null, AgentDefinition? agentDefinition = null, string? instructions = null, string? description = null)
{
var json = s_agentResponseJson;
json = ApplyAgentName(json, agentName);
json = ApplyAgentDefinition(json, agentDefinition);
json = ApplyInstructions(json, instructions);
json = ApplyDescription(json, description);
// Remove the version and id fields to simulate hosted agents without version
json = json.Replace("\"version\": \"1\",", "\"version\": \"\",");
json = json.Replace("\"id\": \"agent_abc123:1\",", "\"id\": \"\",");
return json;
}
/// <summary>
/// Gets the agent version response JSON with whitespace-only version and ID fields for testing hosted agents like MCP agents.
/// </summary>
public static string GetAgentVersionResponseJsonWithWhitespaceVersion(string? agentName = null, AgentDefinition? agentDefinition = null, string? instructions = null, string? description = null)
{
var json = s_agentVersionResponseJson;
json = ApplyAgentName(json, agentName);
json = ApplyAgentDefinition(json, agentDefinition);
json = ApplyInstructions(json, instructions);
json = ApplyDescription(json, description);
// Use whitespace-only version and id fields to simulate hosted agents without version
return json
.Replace("\"version\": \"1\",", "\"version\": \" \",")
.Replace("\"id\": \"agent_abc123:1\",", "\"id\": \" \",");
}
/// <summary>
/// Gets the agent response JSON with whitespace-only version and ID fields in the latest version for testing hosted agents like MCP agents.
/// </summary>
public static string GetAgentResponseJsonWithWhitespaceVersion(string? agentName = null, AgentDefinition? agentDefinition = null, string? instructions = null, string? description = null)
{
var json = s_agentResponseJson;
json = ApplyAgentName(json, agentName);
json = ApplyAgentDefinition(json, agentDefinition);
json = ApplyInstructions(json, instructions);
json = ApplyDescription(json, description);
// Use whitespace-only version and id fields to simulate hosted agents without version
return json
.Replace("\"version\": \"1\",", "\"version\": \" \",")
.Replace("\"id\": \"agent_abc123:1\",", "\"id\": \" \",");
}
/// <summary>
/// Gets the OpenAI default response JSON with optional placeholder replacements applied.
/// </summary>