Fix PR review issues: retry off-by-one, URI encoding, docs, tests, build

- Fix off-by-one in FoundryToolboxBearerTokenHandler retry loop (4 attempts → 3)
- URI-encode version parameter in HostedMcpToolboxAITool.BuildAddress
- Add XML doc clarifying version pinning is reserved for future use
- Add comment clarifying AddHostedService deduplication safety
- Fix DevTemporaryTokenCredential expiry to use DateTimeOffset.MaxValue
- Fix AgentCard ambiguity in A2AServer sample with using alias
- Add 18 new unit tests for retry handler and ReadMcpToolboxMarkers

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
alliscode
2026-04-20 12:10:53 -07:00
Unverified
parent d450865583
commit bbbb78b8df
8 changed files with 307 additions and 14 deletions
@@ -34,7 +34,11 @@ public sealed class HostedMcpToolboxAITool : HostedMcpServerTool
/// Initializes a new instance of the <see cref="HostedMcpToolboxAITool"/> class.
/// </summary>
/// <param name="toolboxName">The Foundry toolbox name.</param>
/// <param name="version">Optional pinned toolbox version. When <see langword="null"/>, the project's default version is used.</param>
/// <param name="version">
/// Optional pinned toolbox version. When <see langword="null"/>, the project's default version is used.
/// Currently reserved for forward compatibility — version-specific routing is handled server-side by
/// the Foundry proxy.
/// </param>
public HostedMcpToolboxAITool(string toolboxName, string? version = null)
: base(
serverName: NotNullOrWhitespace(toolboxName, nameof(toolboxName)),
@@ -63,7 +67,7 @@ public sealed class HostedMcpToolboxAITool : HostedMcpServerTool
return string.IsNullOrEmpty(version)
? $"{UriScheme}://{toolboxName}"
: $"{UriScheme}://{toolboxName}?version={version}";
: $"{UriScheme}://{toolboxName}?version={Uri.EscapeDataString(version)}";
}
/// <summary>
@@ -48,6 +48,7 @@ internal sealed class FoundryToolboxBearerTokenHandler : DelegatingHandler
request.Headers.TryAddWithoutValidation("Foundry-Features", this._featuresHeaderValue);
}
// MaxRetries is the total number of attempts (not additional retries after the first).
for (int attempt = 0; attempt < MaxRetries; attempt++)
{
// Clone the request for retries (the original request cannot be sent twice)
@@ -65,19 +66,20 @@ internal sealed class FoundryToolboxBearerTokenHandler : DelegatingHandler
return response;
}
// Last attempt exhausted — return the error response as-is.
if (attempt == MaxRetries - 1)
{
return response;
}
response.Dispose();
if (attempt < MaxRetries - 1)
{
await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt)), cancellationToken)
.ConfigureAwait(false);
}
await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt)), cancellationToken)
.ConfigureAwait(false);
}
// Final attempt after backoff exhausted — return last response (already disposed above, so resend)
return await base.SendAsync(
await CloneRequestAsync(request, cancellationToken).ConfigureAwait(false),
cancellationToken).ConfigureAwait(false);
// Unreachable when MaxRetries > 0, but satisfies the compiler.
throw new InvalidOperationException("Retry loop completed without returning a response.");
}
private static async Task<HttpRequestMessage> CloneRequestAsync(
@@ -127,7 +127,11 @@ public sealed class FoundryToolboxService : IHostedService, IAsyncDisposable
/// <see cref="FoundryToolboxOptions.StrictMode"/> to either reject or lazily open it.
/// </summary>
/// <param name="toolboxName">The Foundry toolbox name from the marker.</param>
/// <param name="version">Optional pinned version; ignored when matching a pre-registered entry.</param>
/// <param name="version">
/// Optional pinned version. Currently reserved for future use — version-specific routing is
/// handled server-side by the Foundry proxy. This parameter is accepted for forward compatibility
/// but does not affect the proxy URL used to connect to the toolbox.
/// </param>
/// <param name="cancellationToken">The request cancellation token.</param>
/// <exception cref="InvalidOperationException">
/// Thrown when the toolbox is not pre-registered and <see cref="FoundryToolboxOptions.StrictMode"/>
@@ -161,7 +161,8 @@ public static class FoundryHostingExtensions
// Register FoundryToolboxService as a singleton so it can be injected into the handler
services.TryAddSingleton<FoundryToolboxService>();
// Add it as a hosted service so StartAsync is called before the app starts serving requests
// AddHostedService uses TryAddEnumerable internally, so calling AddFoundryToolboxes
// multiple times will not invoke StartAsync twice on the same singleton.
services.AddHostedService(sp => sp.GetRequiredService<FoundryToolboxService>());
return services;