diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Mcp/DefaultMcpToolHandler.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Mcp/DefaultMcpToolHandler.cs
index 61aedec347f..976d2b99815 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Mcp/DefaultMcpToolHandler.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Mcp/DefaultMcpToolHandler.cs
@@ -6,6 +6,7 @@
using System.IO;
using System.Linq;
using System.Net.Http;
+using System.Runtime.ExceptionServices;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
@@ -28,12 +29,15 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.Mcp;
/// a pre-configured for each server.
/// Provider-backed invocations create and dispose a separate MCP session for every call, including
/// tools/list, because provider authentication is not represented in the session cache key.
-/// Without a provider, sessions are cached by server URL, label, connection name, and explicit headers.
+/// Without a provider, workflow invocations are cached by workflow session, server URL, label,
+/// connection name, and explicit headers in a bounded least-recently-used cache. Evicted sessions
+/// are disposed after their active invocations finish.
/// Non-cancellation cleanup failures are reported through warnings without replacing
/// the invocation result or error.
///
-public sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable
+public sealed class DefaultMcpToolHandler : IWorkflowScopedMcpToolHandler, IAsyncDisposable
{
+ private const int DefaultClientCacheMaxSize = 32;
private const string FilenameAdditionalPropertyName = "filename";
///
@@ -46,9 +50,15 @@ public sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable
private readonly Func>? _httpClientProvider;
private readonly Func _httpMessageHandlerFactory;
- private readonly Dictionary<(string Url, string Label, string Connection, string HeadersHash), ClientConnection> _clients = [];
- private readonly Dictionary _ownedHttpClients = [];
+ private readonly Dictionary<(string WorkflowSession, string Url, string Label, string Connection, string HeadersHash), CachedClient> _clients = [];
+ private readonly Dictionary<(string WorkflowSession, string Url, string Label, string Connection, string HeadersHash), TaskCompletionSource> _clientCreations = [];
+ private readonly HashSet> _clientCreationLifetimes = [];
+ private readonly LinkedList<(string WorkflowSession, string Url, string Label, string Connection, string HeadersHash)> _clientLru = [];
+ private readonly HashSet _retiredClients = [];
+ private readonly Dictionary _ownedHttpClients = [];
private readonly SemaphoreSlim _clientLock = new(1, 1);
+ private readonly SemaphoreSlim _clientCreationSemaphore;
+ private readonly int _clientCacheMaxSize;
private readonly AsyncLocal _providerInvocationContext = new();
private TaskCompletionSource? _providerInvocationsDrained;
private int _activeProviderInvocations;
@@ -83,10 +93,18 @@ public DefaultMcpToolHandler(Func>?
internal DefaultMcpToolHandler(
Func>? httpClientProvider,
- Func httpMessageHandlerFactory)
+ Func httpMessageHandlerFactory,
+ int clientCacheMaxSize = DefaultClientCacheMaxSize)
{
+ if (clientCacheMaxSize <= 0)
+ {
+ throw new ArgumentOutOfRangeException(nameof(clientCacheMaxSize), "The MCP client cache size must be positive.");
+ }
+
this._httpClientProvider = httpClientProvider;
this._httpMessageHandlerFactory = Throw.IfNull(httpMessageHandlerFactory);
+ this._clientCacheMaxSize = clientCacheMaxSize;
+ this._clientCreationSemaphore = new(clientCacheMaxSize, clientCacheMaxSize);
}
///
@@ -98,6 +116,26 @@ public async Task InvokeToolAsync(
IDictionary? headers,
string? connectionName,
CancellationToken cancellationToken = default)
+ => await this.InvokeToolInWorkflowSessionAsync(
+ serverUrl,
+ serverLabel,
+ toolName,
+ arguments,
+ headers,
+ connectionName,
+ workflowSessionId: string.Empty,
+ cancellationToken).ConfigureAwait(false);
+
+ ///
+ public async Task InvokeToolInWorkflowSessionAsync(
+ string serverUrl,
+ string? serverLabel,
+ string toolName,
+ IDictionary? arguments,
+ IDictionary? headers,
+ string? connectionName,
+ string workflowSessionId,
+ CancellationToken cancellationToken = default)
{
if (IsListToolsToolName(toolName))
{
@@ -126,7 +164,7 @@ public async Task InvokeToolAsync(
try
{
ClientConnection invocationClient = await this.CreateClientAsync(
- serverUrl.Trim(), serverLabel, headers, httpClientCacheKey: null, cancellationToken).ConfigureAwait(false);
+ serverUrl.Trim(), serverLabel, headers, cancellationToken).ConfigureAwait(false);
await using (invocationClient.ConfigureAwait(false))
{
return await InvokeClientAsync(invocationClient.Client, toolName, arguments, cancellationToken).ConfigureAwait(false);
@@ -151,8 +189,16 @@ public async Task InvokeToolAsync(
}
}
- McpClient client = await this.GetOrCreateClientAsync(serverUrl, serverLabel, headers, connectionName, cancellationToken).ConfigureAwait(false);
- return await InvokeClientAsync(client, toolName, arguments, cancellationToken).ConfigureAwait(false);
+ CachedClient cachedClient = await this.AcquireClientAsync(
+ serverUrl, serverLabel, headers, connectionName, workflowSessionId, cancellationToken).ConfigureAwait(false);
+ try
+ {
+ return await InvokeClientAsync(cachedClient.Connection.Client, toolName, arguments, cancellationToken).ConfigureAwait(false);
+ }
+ finally
+ {
+ await this.ReleaseClientAsync(cachedClient).ConfigureAwait(false);
+ }
}
private static async Task InvokeClientAsync(
@@ -218,12 +264,14 @@ public async ValueTask DisposeAsync()
}
Task? providerInvocations;
+ List> clientCreationLifetimes;
await this._clientLock.WaitAsync().ConfigureAwait(false);
try
{
this.ThrowIfDisposing();
this._disposing = true;
providerInvocations = this._providerInvocationsDrained?.Task;
+ clientCreationLifetimes = this._clientCreationLifetimes.Select(source => source.Task).ToList();
}
finally
{
@@ -235,32 +283,79 @@ public async ValueTask DisposeAsync()
await providerInvocations.ConfigureAwait(false);
}
+ foreach (Task clientCreationLifetime in clientCreationLifetimes)
+ {
+ await clientCreationLifetime.ConfigureAwait(false);
+ }
+
+ List cachedClients;
+ List clientsToDispose = [];
await this._clientLock.WaitAsync().ConfigureAwait(false);
try
{
- foreach (ClientConnection client in this._clients.Values)
+ cachedClients = [.. this._clients.Values, .. this._retiredClients];
+ foreach (CachedClient client in cachedClients)
{
- await client.DisposeAsync().ConfigureAwait(false);
+ client.Evicted = true;
+ this._retiredClients.Add(client);
+ if (client.ActiveInvocations == 0 && !client.DisposalClaimed)
+ {
+ client.DisposalClaimed = true;
+ clientsToDispose.Add(client);
+ }
}
this._clients.Clear();
-
- // Dispose only HttpClients that the handler created (not user-provided ones)
- foreach (HttpClient httpClient in this._ownedHttpClients.Values)
- {
- httpClient.Dispose();
- }
-
- this._ownedHttpClients.Clear();
+ this._clientLru.Clear();
}
finally
{
this._clientLock.Release();
}
- this._clientLock.Dispose();
+ try
+ {
+ await DrainCleanupAsync(
+ clientsToDispose.Select(client => this.DisposeCachedClientAsync(client)),
+ cachedClients.Select(client => client.Disposed.Task)).ConfigureAwait(false);
+ }
+ finally
+ {
+ this._clientLock.Dispose();
+ this._clientCreationSemaphore.Dispose();
+ }
}
+ internal static async Task DrainCleanupAsync(IEnumerable cleanupTasks, IEnumerable completionTasks)
+ {
+ Exception? cleanupException = null;
+ try
+ {
+ await Task.WhenAll(cleanupTasks).ConfigureAwait(false);
+ }
+ catch (Exception exception) when (!IsFatalException(exception))
+ {
+ cleanupException = exception;
+ }
+
+ await Task.WhenAll(completionTasks).ConfigureAwait(false);
+
+ if (cleanupException is not null)
+ {
+ ExceptionDispatchInfo.Capture(cleanupException).Throw();
+ }
+ }
+
+ private static bool IsFatalException(Exception exception) =>
+ exception is OutOfMemoryException and not InsufficientMemoryException
+ or StackOverflowException
+ or AccessViolationException
+ or AppDomainUnloadedException
+ or BadImageFormatException
+ or CannotUnloadAppDomainException
+ or InvalidProgramException
+ or ThreadAbortException;
+
[System.Diagnostics.CodeAnalysis.SuppressMessage("Maintainability", "CA1513:Use ObjectDisposedException throw helper",
Justification = "The helper is not available on .NET Framework or .NET Standard 2.0.")]
private void ThrowIfDisposing()
@@ -271,89 +366,336 @@ private void ThrowIfDisposing()
}
}
- private async Task GetOrCreateClientAsync(
+ private async Task AcquireClientAsync(
string serverUrl,
string? serverLabel,
IDictionary? headers,
string? connectionName,
+ string workflowSessionId,
CancellationToken cancellationToken)
{
string trimmedUrl = serverUrl.Trim();
- var clientCacheKey = BuildCacheKey(trimmedUrl, serverLabel, connectionName, headers);
+ var clientCacheKey = BuildCacheKey(workflowSessionId, trimmedUrl, serverLabel, connectionName, headers);
+ CachedClient? clientToDispose = null;
+ TaskCompletionSource? clientCreation;
+ TaskCompletionSource? clientCreationLifetime = null;
+ bool ownsClientCreation = false;
await this._clientLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
this.ThrowIfDisposing();
- if (this._clients.TryGetValue(clientCacheKey, out ClientConnection? existingClient))
+ if (this._clients.TryGetValue(clientCacheKey, out CachedClient? existingClient))
+ {
+ existingClient.ActiveInvocations++;
+ this._clientLru.Remove(existingClient.LruNode);
+ this._clientLru.AddLast(existingClient.LruNode);
+ return existingClient;
+ }
+
+ if (!this._clientCreations.TryGetValue(clientCacheKey, out clientCreation))
+ {
+ clientCreation = new(TaskCreationOptions.RunContinuationsAsynchronously);
+ this._clientCreations[clientCacheKey] = clientCreation;
+ clientCreationLifetime = new(TaskCreationOptions.RunContinuationsAsynchronously);
+ this._clientCreationLifetimes.Add(clientCreationLifetime);
+ ownsClientCreation = true;
+ }
+ }
+ finally
+ {
+ this._clientLock.Release();
+ }
+
+ if (!ownsClientCreation)
+ {
+ try
+ {
+ await WaitForClientCreationAsync(clientCreation.Task, cancellationToken).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
+ {
+ return await this.AcquireClientAsync(
+ serverUrl, serverLabel, headers, connectionName, workflowSessionId, cancellationToken).ConfigureAwait(false);
+ }
+
+ return await this.AcquireClientAsync(
+ serverUrl, serverLabel, headers, connectionName, workflowSessionId, cancellationToken).ConfigureAwait(false);
+ }
+
+ TaskCompletionSource ownedClientCreationLifetime = clientCreationLifetime ??
+ throw new InvalidOperationException("Missing MCP client creation lifetime.");
+ ClientConnection? connection = null;
+ bool creationSemaphoreEntered = false;
+ try
+ {
+ await this._clientCreationSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
+ creationSemaphoreEntered = true;
+ await this._clientLock.WaitAsync(cancellationToken).ConfigureAwait(false);
+ try
+ {
+ this.ThrowIfDisposing();
+ }
+ finally
+ {
+ this._clientLock.Release();
+ }
+
+ connection = await this.CreateClientAsync(
+ trimmedUrl, serverLabel, headers, cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception exception)
+ {
+ try
+ {
+ await this.CompleteClientCreationFailureAsync(clientCacheKey, clientCreation, exception).ConfigureAwait(false);
+ }
+ finally
+ {
+ if (creationSemaphoreEntered)
+ {
+ this._clientCreationSemaphore.Release();
+ creationSemaphoreEntered = false;
+ }
+
+ await this.CompleteClientCreationLifetimeAsync(ownedClientCreationLifetime).ConfigureAwait(false);
+ }
+
+ throw;
+ }
+
+ ObjectDisposedException? disposedException = null;
+ CachedClient? result = null;
+ await this._clientLock.WaitAsync(CancellationToken.None).ConfigureAwait(false);
+ try
+ {
+ this._clientCreations.Remove(clientCacheKey);
+ if (this._disposing)
+ {
+ disposedException = new ObjectDisposedException(nameof(DefaultMcpToolHandler));
+ }
+ else
+ {
+ LinkedListNode<(string WorkflowSession, string Url, string Label, string Connection, string HeadersHash)> node =
+ this._clientLru.AddLast(clientCacheKey);
+ CachedClient newClient = new(connection, node) { ActiveInvocations = 1 };
+ connection = null;
+ this._clients[clientCacheKey] = newClient;
+
+ if (this._clients.Count > this._clientCacheMaxSize)
+ {
+ LinkedListNode<(string WorkflowSession, string Url, string Label, string Connection, string HeadersHash)> evictedNode =
+ this._clientLru.First!;
+ this._clientLru.RemoveFirst();
+ CachedClient evictedClient = this._clients[evictedNode.Value];
+ this._clients.Remove(evictedNode.Value);
+ evictedClient.Evicted = true;
+ this._retiredClients.Add(evictedClient);
+ if (evictedClient.ActiveInvocations == 0)
+ {
+ evictedClient.DisposalClaimed = true;
+ clientToDispose = evictedClient;
+ }
+ }
+
+ result = newClient;
+ clientCreation.TrySetResult(newClient);
+ }
+ }
+ finally
+ {
+ this._clientLock.Release();
+ }
+
+ try
+ {
+ if (connection is not null)
+ {
+ await connection.DisposeAsync().ConfigureAwait(false);
+ }
+
+ if (disposedException is not null)
+ {
+ clientCreation.TrySetException(disposedException);
+ throw disposedException;
+ }
+
+ if (clientToDispose is not null)
+ {
+ await this.DisposeCachedClientAsync(clientToDispose).ConfigureAwait(false);
+ }
+ }
+ catch (Exception exception)
+ {
+ if (disposedException is not null)
+ {
+ clientCreation.TrySetException(exception);
+ }
+
+ if (result is not null)
+ {
+ await this.ReleaseClientAsync(result).ConfigureAwait(false);
+ }
+
+ throw;
+ }
+ finally
+ {
+ if (creationSemaphoreEntered)
+ {
+ this._clientCreationSemaphore.Release();
+ }
+
+ await this.CompleteClientCreationLifetimeAsync(ownedClientCreationLifetime).ConfigureAwait(false);
+ }
+
+ return result ?? throw new InvalidOperationException("Failed to acquire MCP client.");
+ }
+
+ private static async Task WaitForClientCreationAsync(Task clientCreation, CancellationToken cancellationToken)
+ {
+ if (!cancellationToken.CanBeCanceled || clientCreation.IsCompleted)
+ {
+ await clientCreation.ConfigureAwait(false);
+ return;
+ }
+
+ TaskCompletionSource cancellation = new(TaskCreationOptions.RunContinuationsAsynchronously);
+ using CancellationTokenRegistration registration = cancellationToken.Register(
+ static state => ((TaskCompletionSource)state!).TrySetResult(true),
+ cancellation);
+
+ if (await Task.WhenAny(clientCreation, cancellation.Task).ConfigureAwait(false) == cancellation.Task)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ }
+
+ await clientCreation.ConfigureAwait(false);
+ }
+
+ private async Task CompleteClientCreationFailureAsync(
+ (string WorkflowSession, string Url, string Label, string Connection, string HeadersHash) clientCacheKey,
+ TaskCompletionSource clientCreation,
+ Exception exception)
+ {
+ await this._clientLock.WaitAsync(CancellationToken.None).ConfigureAwait(false);
+ try
+ {
+ if (this._clientCreations.TryGetValue(clientCacheKey, out TaskCompletionSource? existingCreation) &&
+ ReferenceEquals(existingCreation, clientCreation))
{
- return existingClient.Client;
+ this._clientCreations.Remove(clientCacheKey);
}
- ClientConnection newClient = await this.CreateClientAsync(trimmedUrl, serverLabel, headers, trimmedUrl, cancellationToken).ConfigureAwait(false);
- this._clients[clientCacheKey] = newClient;
- return newClient.Client;
+ clientCreation.TrySetException(exception);
+ _ = clientCreation.Task.Exception;
+ }
+ finally
+ {
+ this._clientLock.Release();
+ }
+ }
+
+ private async Task CompleteClientCreationLifetimeAsync(TaskCompletionSource clientCreationLifetime)
+ {
+ await this._clientLock.WaitAsync(CancellationToken.None).ConfigureAwait(false);
+ try
+ {
+ this._clientCreationLifetimes.Remove(clientCreationLifetime);
}
finally
{
this._clientLock.Release();
}
+
+ clientCreationLifetime.TrySetResult(true);
+ }
+
+ private async ValueTask ReleaseClientAsync(CachedClient client)
+ {
+ bool dispose = false;
+ await this._clientLock.WaitAsync(CancellationToken.None).ConfigureAwait(false);
+ try
+ {
+ client.ActiveInvocations--;
+ if (client.ActiveInvocations == 0 && client.Evicted && !client.DisposalClaimed)
+ {
+ client.DisposalClaimed = true;
+ dispose = true;
+ }
+ }
+ finally
+ {
+ this._clientLock.Release();
+ }
+
+ if (dispose)
+ {
+ await this.DisposeCachedClientAsync(client).ConfigureAwait(false);
+ }
+ }
+
+ private async Task DisposeCachedClientAsync(CachedClient client)
+ {
+ try
+ {
+ await client.Connection.DisposeAsync().ConfigureAwait(false);
+ }
+ finally
+ {
+ await this._clientLock.WaitAsync(CancellationToken.None).ConfigureAwait(false);
+ try
+ {
+ this._retiredClients.Remove(client);
+ }
+ finally
+ {
+ this._clientLock.Release();
+ }
+
+ client.Disposed.TrySetResult(true);
+ }
}
///
- /// Builds the per-client cache key as a 4-tuple of
- /// (trimmed serverUrl, serverLabel, connectionName, headers hash). All four components
- /// participate so that callers using different labels/connections/headers receive
- /// distinct instances even when targeting the same URL.
+ /// Builds the per-client cache key as a 5-tuple of
+ /// (workflowSessionId, trimmed serverUrl, serverLabel, connectionName, headers hash).
+ /// All five components participate so that separate workflow sessions and callers using
+ /// different labels/connections/headers receive distinct instances
+ /// even when targeting the same URL.
///
- internal static (string Url, string Label, string Connection, string HeadersHash) BuildCacheKey(
+ internal static (string WorkflowSession, string Url, string Label, string Connection, string HeadersHash) BuildCacheKey(
+ string workflowSessionId,
string trimmedUrl,
string? serverLabel,
string? connectionName,
IDictionary? headers) =>
- (trimmedUrl, serverLabel ?? string.Empty, connectionName ?? string.Empty, ComputeHeadersHash(headers));
+ (workflowSessionId, trimmedUrl, serverLabel ?? string.Empty, connectionName ?? string.Empty, ComputeHeadersHash(headers));
private async Task CreateClientAsync(
string serverUrl,
string? serverLabel,
IDictionary? headers,
- string? httpClientCacheKey,
CancellationToken cancellationToken)
{
- // Only the no-provider path shares handler-owned HTTP clients.
HttpClient? httpClient = null;
bool ownsHttpClient = false;
+ OwnedHttpClientLease? ownedHttpClientLease = null;
if (this._httpClientProvider is not null)
{
httpClient = await this._httpClientProvider(serverUrl, cancellationToken).ConfigureAwait(false);
}
- if (httpClient is null &&
- (httpClientCacheKey is null || !this._ownedHttpClients.TryGetValue(httpClientCacheKey, out httpClient)))
+ if (httpClient is null && this._httpClientProvider is not null)
{
- // Pin credential headers to the configured server origin as defense-in-depth. Forcing
- // StreamableHttp (below) already removes the primary vector (a server-advertised cross-origin
- // SSE message endpoint), and AllowAutoRedirect=false blocks auto-redirects. This handler is the
- // backstop: it guarantees the Authorization token and other credentials never leave the pinned
- // origin even if a future change re-enables AutoDetect or redirects, or the SDK constructs a
- // request to a new URI (AdditionalHeaders are re-stamped by the transport, so HttpClient's own
- // redirect header-stripping does not cover them).
- // Caveat for future maintainers: the handler strips credentials on every cross-origin request.
- // That is safe today because this handler carries auth via static request headers, not the
- // SDK's built-in OAuth flow. If OAuth support is ever added here, requests to the authorization
- // server (a different origin than the MCP resource server) legitimately carry credentials, so
- // those auth-server origins would need to be allow-listed to avoid breaking the token exchange.
- OriginPinningHandler pinningHandler = new(new Uri(serverUrl)) { InnerHandler = this._httpMessageHandlerFactory() };
- httpClient = new HttpClient(pinningHandler);
- if (httpClientCacheKey is null)
- {
- ownsHttpClient = true;
- }
- else
- {
- this._ownedHttpClients[httpClientCacheKey] = httpClient;
- }
+ httpClient = this.CreatePinnedHttpClient(serverUrl);
+ ownsHttpClient = true;
+ }
+ else if (httpClient is null)
+ {
+ ownedHttpClientLease = await this.AcquireOwnedHttpClientAsync(serverUrl, cancellationToken).ConfigureAwait(false);
+ httpClient = ownedHttpClientLease.Client;
}
HttpClientTransportOptions transportOptions = new()
@@ -368,20 +710,93 @@ private async Task CreateClientAsync(
TransportMode = HttpTransportMode.StreamableHttp
};
- HttpClientTransport transport = new(transportOptions, httpClient, ownsHttpClient: ownsHttpClient);
-
+ HttpClientTransport? transport = null;
try
{
+ HttpClient resolvedHttpClient = httpClient ?? throw new InvalidOperationException("Failed to resolve MCP HTTP client.");
+ transport = new(transportOptions, resolvedHttpClient, ownsHttpClient: ownsHttpClient);
McpClient client = await McpClient.CreateAsync(transport, cancellationToken: cancellationToken).ConfigureAwait(false);
- return new ClientConnection(client, transport);
+ ClientConnection connection = new(client, transport, ownedHttpClientLease);
+ ownedHttpClientLease = null;
+ return connection;
}
catch
{
- await DisposeResourceAsync(transport, "transport").ConfigureAwait(false);
+ try
+ {
+ if (transport is not null)
+ {
+ await DisposeResourceAsync(transport, "transport").ConfigureAwait(false);
+ }
+ }
+ finally
+ {
+ if (ownedHttpClientLease is not null)
+ {
+ await ownedHttpClientLease.DisposeAsync().ConfigureAwait(false);
+ }
+ }
+
throw;
}
}
+ private async Task AcquireOwnedHttpClientAsync(
+ string serverUrl,
+ CancellationToken cancellationToken)
+ {
+ await this._clientLock.WaitAsync(cancellationToken).ConfigureAwait(false);
+ try
+ {
+ if (!this._ownedHttpClients.TryGetValue(serverUrl, out OwnedHttpClient? entry))
+ {
+ entry = new(this.CreatePinnedHttpClient(serverUrl));
+ this._ownedHttpClients[serverUrl] = entry;
+ }
+
+ entry.ReferenceCount++;
+ return new(this, serverUrl, entry.Client);
+ }
+ finally
+ {
+ this._clientLock.Release();
+ }
+ }
+
+ private async ValueTask ReleaseOwnedHttpClientAsync(string serverUrl)
+ {
+ HttpClient? clientToDispose = null;
+ await this._clientLock.WaitAsync(CancellationToken.None).ConfigureAwait(false);
+ try
+ {
+ OwnedHttpClient entry = this._ownedHttpClients[serverUrl];
+ if (--entry.ReferenceCount == 0)
+ {
+ this._ownedHttpClients.Remove(serverUrl);
+ clientToDispose = entry.Client;
+ }
+ }
+ finally
+ {
+ this._clientLock.Release();
+ }
+
+ clientToDispose?.Dispose();
+ }
+
+ private HttpClient CreatePinnedHttpClient(string serverUrl)
+ {
+ // Pin credential headers to the configured server origin as defense-in-depth. Forcing
+ // StreamableHttp (below) already removes the primary vector (a server-advertised cross-origin
+ // SSE message endpoint), and AllowAutoRedirect=false blocks auto-redirects. This handler is the
+ // backstop: it guarantees the Authorization token and other credentials never leave the pinned
+ // origin even if a future change re-enables AutoDetect or redirects, or the SDK constructs a
+ // request to a new URI (AdditionalHeaders are re-stamped by the transport, so HttpClient's own
+ // redirect header-stripping does not cover them).
+ OriginPinningHandler pinningHandler = new(new Uri(serverUrl)) { InnerHandler = this._httpMessageHandlerFactory() };
+ return new HttpClient(pinningHandler);
+ }
+
private static HttpMessageHandler CreateHttpMessageHandler() =>
new HttpClientHandler
{
@@ -398,7 +813,53 @@ private sealed class ProviderInvocationContext(Task completion, ProviderInvocati
public ProviderInvocationContext? Parent { get; } = parent;
}
- internal sealed class ClientConnection(McpClient client, IAsyncDisposable transport) : IAsyncDisposable
+ private sealed class CachedClient(
+ ClientConnection connection,
+ LinkedListNode<(string WorkflowSession, string Url, string Label, string Connection, string HeadersHash)> lruNode)
+ {
+ public ClientConnection Connection { get; } = connection;
+
+ public LinkedListNode<(string WorkflowSession, string Url, string Label, string Connection, string HeadersHash)> LruNode { get; } = lruNode;
+
+ public TaskCompletionSource Disposed { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ public int ActiveInvocations { get; set; }
+
+ public bool Evicted { get; set; }
+
+ public bool DisposalClaimed { get; set; }
+ }
+
+ private sealed class OwnedHttpClient(HttpClient client)
+ {
+ public HttpClient Client { get; } = client;
+
+ public int ReferenceCount { get; set; }
+ }
+
+ private sealed class OwnedHttpClientLease(
+ DefaultMcpToolHandler owner,
+ string serverUrl,
+ HttpClient client) : IAsyncDisposable
+ {
+ private bool _disposed;
+
+ public HttpClient Client { get; } = client;
+
+ public async ValueTask DisposeAsync()
+ {
+ if (!this._disposed)
+ {
+ this._disposed = true;
+ await owner.ReleaseOwnedHttpClientAsync(serverUrl).ConfigureAwait(false);
+ }
+ }
+ }
+
+ internal sealed class ClientConnection(
+ McpClient client,
+ IAsyncDisposable transport,
+ IAsyncDisposable? ownedHttpClientLease = null) : IAsyncDisposable
{
public McpClient Client { get; } = client;
@@ -410,8 +871,18 @@ public async ValueTask DisposeAsync()
}
finally
{
- // McpClient owns the connected session, not the reusable transport factory.
- await DisposeResourceAsync(transport, "transport").ConfigureAwait(false);
+ try
+ {
+ // McpClient owns the connected session, not the reusable transport factory.
+ await DisposeResourceAsync(transport, "transport").ConfigureAwait(false);
+ }
+ finally
+ {
+ if (ownedHttpClientLease is not null)
+ {
+ await ownedHttpClientLease.DisposeAsync().ConfigureAwait(false);
+ }
+ }
}
}
}
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Mcp/PublicAPI/net10.0/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Mcp/PublicAPI/net10.0/PublicAPI.Unshipped.txt
index ab058de62d4..3cdebd5e1a0 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Mcp/PublicAPI/net10.0/PublicAPI.Unshipped.txt
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Mcp/PublicAPI/net10.0/PublicAPI.Unshipped.txt
@@ -1 +1,2 @@
#nullable enable
+Microsoft.Agents.AI.Workflows.Declarative.Mcp.DefaultMcpToolHandler.InvokeToolInWorkflowSessionAsync(string! serverUrl, string? serverLabel, string! toolName, System.Collections.Generic.IDictionary? arguments, System.Collections.Generic.IDictionary? headers, string? connectionName, string! workflowSessionId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Mcp/PublicAPI/net472/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Mcp/PublicAPI/net472/PublicAPI.Unshipped.txt
index ab058de62d4..3cdebd5e1a0 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Mcp/PublicAPI/net472/PublicAPI.Unshipped.txt
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Mcp/PublicAPI/net472/PublicAPI.Unshipped.txt
@@ -1 +1,2 @@
#nullable enable
+Microsoft.Agents.AI.Workflows.Declarative.Mcp.DefaultMcpToolHandler.InvokeToolInWorkflowSessionAsync(string! serverUrl, string? serverLabel, string! toolName, System.Collections.Generic.IDictionary? arguments, System.Collections.Generic.IDictionary? headers, string? connectionName, string! workflowSessionId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Mcp/PublicAPI/net8.0/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Mcp/PublicAPI/net8.0/PublicAPI.Unshipped.txt
index ab058de62d4..3cdebd5e1a0 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Mcp/PublicAPI/net8.0/PublicAPI.Unshipped.txt
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Mcp/PublicAPI/net8.0/PublicAPI.Unshipped.txt
@@ -1 +1,2 @@
#nullable enable
+Microsoft.Agents.AI.Workflows.Declarative.Mcp.DefaultMcpToolHandler.InvokeToolInWorkflowSessionAsync(string! serverUrl, string? serverLabel, string! toolName, System.Collections.Generic.IDictionary? arguments, System.Collections.Generic.IDictionary? headers, string? connectionName, string! workflowSessionId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Mcp/PublicAPI/net9.0/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Mcp/PublicAPI/net9.0/PublicAPI.Unshipped.txt
index ab058de62d4..3cdebd5e1a0 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Mcp/PublicAPI/net9.0/PublicAPI.Unshipped.txt
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Mcp/PublicAPI/net9.0/PublicAPI.Unshipped.txt
@@ -1 +1,2 @@
#nullable enable
+Microsoft.Agents.AI.Workflows.Declarative.Mcp.DefaultMcpToolHandler.InvokeToolInWorkflowSessionAsync(string! serverUrl, string? serverLabel, string! toolName, System.Collections.Generic.IDictionary? arguments, System.Collections.Generic.IDictionary? headers, string? connectionName, string! workflowSessionId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Mcp/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Mcp/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt
index ab058de62d4..3cdebd5e1a0 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Mcp/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Mcp/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt
@@ -1 +1,2 @@
#nullable enable
+Microsoft.Agents.AI.Workflows.Declarative.Mcp.DefaultMcpToolHandler.InvokeToolInWorkflowSessionAsync(string! serverUrl, string? serverLabel, string! toolName, System.Collections.Generic.IDictionary? arguments, System.Collections.Generic.IDictionary? headers, string? connectionName, string! workflowSessionId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/IMcpToolHandler.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/IMcpToolHandler.cs
index 56b1c3deb4c..fbea21f35b4 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/IMcpToolHandler.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/IMcpToolHandler.cs
@@ -39,3 +39,34 @@ Task InvokeToolAsync(
string? connectionName,
CancellationToken cancellationToken = default);
}
+
+///
+/// Defines the contract for MCP handlers that isolate stateful protocol sessions by workflow session.
+///
+public interface IWorkflowScopedMcpToolHandler : IMcpToolHandler
+{
+ ///
+ /// Invokes an MCP tool within the framework-owned workflow session scope.
+ ///
+ /// The URL of the MCP server.
+ /// An optional label identifying the server connection.
+ /// The name of the tool to invoke.
+ /// Optional arguments to pass to the tool.
+ /// Optional headers to include in the request.
+ /// An optional connection name for managed connections.
+ /// The framework-owned identifier for the current workflow session.
+ /// A token to observe cancellation.
+ ///
+ /// A task representing the asynchronous operation. The result contains a
+ /// with the tool invocation output.
+ ///
+ Task InvokeToolInWorkflowSessionAsync(
+ string serverUrl,
+ string? serverLabel,
+ string toolName,
+ IDictionary? arguments,
+ IDictionary? headers,
+ string? connectionName,
+ string workflowSessionId,
+ CancellationToken cancellationToken = default);
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/DeclarativeActionExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/DeclarativeActionExecutor.cs
index 597ea831296..fbbb739a55d 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/DeclarativeActionExecutor.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/DeclarativeActionExecutor.cs
@@ -89,7 +89,9 @@ public override async ValueTask HandleAsync(ActionExecutorResult message, IWorkf
try
{
- object? result = await this.ExecuteAsync(new DeclarativeWorkflowContext(context, this.State), cancellationToken).ConfigureAwait(false);
+ DeclarativeWorkflowContext declarativeContext =
+ await DeclarativeWorkflowContext.CreateAsync(context, this.State, cancellationToken).ConfigureAwait(false);
+ object? result = await this.ExecuteAsync(declarativeContext, cancellationToken).ConfigureAwait(false);
Debug.WriteLine($"RESULT #{this.Id} - {result ?? "(null)"}");
if (this.EmitResultEvent)
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/DeclarativeWorkflowContext.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/DeclarativeWorkflowContext.cs
index 183b2e785e7..f88035abd12 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/DeclarativeWorkflowContext.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/DeclarativeWorkflowContext.cs
@@ -15,8 +15,10 @@
namespace Microsoft.Agents.AI.Workflows.Declarative.Interpreter;
-internal sealed class DeclarativeWorkflowContext : IWorkflowContext
+internal sealed class DeclarativeWorkflowContext : IWorkflowContext, IWorkflowSessionContext
{
+ internal const string WorkflowSessionIdStateKey = "__declarative_mcp_workflow_session_id";
+
public static readonly FrozenSet ManagedScopes =
[
VariableScopeNames.Local,
@@ -24,16 +26,36 @@ internal sealed class DeclarativeWorkflowContext : IWorkflowContext
VariableScopeNames.Global,
];
- public DeclarativeWorkflowContext(IWorkflowContext source, WorkflowFormulaState state)
+ private DeclarativeWorkflowContext(IWorkflowContext source, WorkflowFormulaState state, string sessionId)
{
this.Source = source;
this.State = state;
+ this.SessionId = sessionId;
+ }
+
+ public static async ValueTask CreateAsync(
+ IWorkflowContext source,
+ WorkflowFormulaState state,
+ CancellationToken cancellationToken = default)
+ {
+ string generatedSessionId = Guid.NewGuid().ToString("N");
+ string sessionId = source is IWorkflowSessionContext sessionContext
+ ? sessionContext.SessionId
+ : await source.ReadOrInitStateAsync(
+ WorkflowSessionIdStateKey,
+ () => generatedSessionId,
+ VariableScopeNames.System,
+ cancellationToken: cancellationToken).ConfigureAwait(false);
+
+ return new(source, state, sessionId);
}
private IWorkflowContext Source { get; }
public WorkflowFormulaState State { get; }
public IReadOnlyDictionary? TraceContext => this.Source.TraceContext;
+ public string SessionId { get; }
+
///
public bool ConcurrentRunsEnabled => this.Source.ConcurrentRunsEnabled;
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/DeclarativeWorkflowExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/DeclarativeWorkflowExecutor.cs
index 1126b43d8ff..2fcdea4ecce 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/DeclarativeWorkflowExecutor.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/DeclarativeWorkflowExecutor.cs
@@ -154,7 +154,8 @@ private async ValueTask AdvanceAsync(ChatMessage input, IWorkflowContext context
// No state to restore if we're starting from the beginning.
state.SetInitialized();
- DeclarativeWorkflowContext declarativeContext = new(context, state);
+ DeclarativeWorkflowContext declarativeContext =
+ await DeclarativeWorkflowContext.CreateAsync(context, state, cancellationToken).ConfigureAwait(false);
// Conversation id resolution prefers state already persisted by a prior turn,
// so multi-turn invocations reuse the same backend conversation rather than
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/DelegateActionExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/DelegateActionExecutor.cs
index 716a3656dac..ade8dd1034b 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/DelegateActionExecutor.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/DelegateActionExecutor.cs
@@ -61,7 +61,9 @@ public override async ValueTask HandleAsync(TMessage message, IWorkflowContext c
{
if (this._action is not null)
{
- await this._action.Invoke(new DeclarativeWorkflowContext(context, this._state), message, cancellationToken).ConfigureAwait(false);
+ DeclarativeWorkflowContext declarativeContext =
+ await DeclarativeWorkflowContext.CreateAsync(context, this._state, cancellationToken).ConfigureAwait(false);
+ await this._action.Invoke(declarativeContext, message, cancellationToken).ConfigureAwait(false);
}
if (this._emitResult)
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Kit/ActionExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Kit/ActionExecutor.cs
index cf636effaf8..563ff86f147 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Kit/ActionExecutor.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Kit/ActionExecutor.cs
@@ -76,7 +76,9 @@ public ValueTask ResetAsync()
[SendsMessage(typeof(ActionExecutorResult))]
public override async ValueTask HandleAsync(TMessage message, IWorkflowContext context, CancellationToken cancellationToken)
{
- object? result = await this.ExecuteAsync(new DeclarativeWorkflowContext(context, this._session.State), message, cancellationToken).ConfigureAwait(false);
+ DeclarativeWorkflowContext declarativeContext =
+ await DeclarativeWorkflowContext.CreateAsync(context, this._session.State, cancellationToken).ConfigureAwait(false);
+ object? result = await this.ExecuteAsync(declarativeContext, message, cancellationToken).ConfigureAwait(false);
Debug.WriteLine($"RESULT #{this.Id} - {result ?? "(null)"}");
await context.SendResultMessageAsync(this.Id, result, cancellationToken).ConfigureAwait(false);
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Kit/RootExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Kit/RootExecutor.cs
index 3a18a06c948..9e008ed391b 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Kit/RootExecutor.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Kit/RootExecutor.cs
@@ -63,7 +63,8 @@ public ValueTask ResetAsync()
[SendsMessage(typeof(ActionExecutorResult))]
public override async ValueTask HandleAsync(TInput message, IWorkflowContext context, CancellationToken cancellationToken)
{
- DeclarativeWorkflowContext declarativeContext = new(context, this._state);
+ DeclarativeWorkflowContext declarativeContext =
+ await DeclarativeWorkflowContext.CreateAsync(context, this._state, cancellationToken).ConfigureAwait(false);
ChatMessage input = (this._inputTransform ?? DefaultInputTransform).Invoke(message);
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeMcpToolExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeMcpToolExecutor.cs
index 46a5cae5fdd..ed74d30d39d 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeMcpToolExecutor.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeMcpToolExecutor.cs
@@ -31,7 +31,6 @@ internal sealed class InvokeMcpToolExecutor(
{
private const string ApprovalSnapshotStateKey = nameof(_approvalSnapshots);
private const string LegacyApprovalSnapshotStateKey = "_approvalSnapshot";
-
///
/// Snapshots of evaluated parameters captured at approval-request time, keyed by
/// per-invocation request id. Each pending approval lives here until the matching
@@ -115,7 +114,8 @@ public static bool RequiresNothing(object? message) =>
}
// No approval required - invoke the tool directly
- McpServerToolResultContent resultContent = await mcpToolHandler.InvokeToolAsync(
+ McpServerToolResultContent resultContent = await this.InvokeToolAsync(
+ context,
serverUrl,
serverLabel,
toolName,
@@ -172,7 +172,8 @@ public async ValueTask CaptureResponseAsync(
Dictionary? headers = this.GetHeaders();
- McpServerToolResultContent resultContent = await mcpToolHandler.InvokeToolAsync(
+ McpServerToolResultContent resultContent = await this.InvokeToolAsync(
+ context,
snapshot.ServerUrl,
snapshot.ServerLabel,
snapshot.ToolName,
@@ -184,6 +185,47 @@ public async ValueTask CaptureResponseAsync(
await this.ProcessResultAsync(context, resultContent, cancellationToken).ConfigureAwait(false);
}
+ private async Task InvokeToolAsync(
+ IWorkflowContext context,
+ string serverUrl,
+ string? serverLabel,
+ string toolName,
+ IDictionary? arguments,
+ IDictionary? headers,
+ string? connectionName,
+ CancellationToken cancellationToken)
+ {
+ if (mcpToolHandler is IWorkflowScopedMcpToolHandler scopedHandler)
+ {
+ string generatedWorkflowSessionId = Guid.NewGuid().ToString("N");
+ string workflowSessionId = context is IWorkflowSessionContext sessionContext
+ ? sessionContext.SessionId
+ : await context.ReadOrInitStateAsync(
+ DeclarativeWorkflowContext.WorkflowSessionIdStateKey,
+ () => generatedWorkflowSessionId,
+ VariableScopeNames.System,
+ cancellationToken).ConfigureAwait(false);
+ return await scopedHandler.InvokeToolInWorkflowSessionAsync(
+ serverUrl,
+ serverLabel,
+ toolName,
+ arguments,
+ headers,
+ connectionName,
+ workflowSessionId,
+ cancellationToken).ConfigureAwait(false);
+ }
+
+ return await mcpToolHandler.InvokeToolAsync(
+ serverUrl,
+ serverLabel,
+ toolName,
+ arguments,
+ headers,
+ connectionName,
+ cancellationToken).ConfigureAwait(false);
+ }
+
///
/// Completes the MCP tool invocation by raising the completion event.
///
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net10.0/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net10.0/PublicAPI.Unshipped.txt
index 8af74cb2369..5bee0f03310 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net10.0/PublicAPI.Unshipped.txt
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net10.0/PublicAPI.Unshipped.txt
@@ -4,6 +4,8 @@ Microsoft.Agents.AI.Workflows.Declarative.DeclarativeWorkflowOptions.AllowedEnvi
Microsoft.Agents.AI.Workflows.Declarative.DeclarativeWorkflowOptions.AllowProcessEnvironmentVariableFallback.get -> bool
Microsoft.Agents.AI.Workflows.Declarative.DeclarativeWorkflowOptions.AllowProcessEnvironmentVariableFallback.init -> void
Microsoft.Agents.AI.Workflows.Declarative.Events.ExternalInputResponse.RequestId.get -> string?
+Microsoft.Agents.AI.Workflows.Declarative.IWorkflowScopedMcpToolHandler
+Microsoft.Agents.AI.Workflows.Declarative.IWorkflowScopedMcpToolHandler.InvokeToolInWorkflowSessionAsync(string! serverUrl, string? serverLabel, string! toolName, System.Collections.Generic.IDictionary? arguments, System.Collections.Generic.IDictionary? headers, string? connectionName, string! workflowSessionId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.ConvertValueWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, Microsoft.Agents.AI.Workflows.Declarative.Kit.VariableType! targetType, string! key, string? scopeName = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask!>
static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.EvaluateValueWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, string! expression, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask!>
static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.FormatTemplateWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, string! line) -> System.Threading.Tasks.ValueTask!>
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net472/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net472/PublicAPI.Unshipped.txt
index 8af74cb2369..5bee0f03310 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net472/PublicAPI.Unshipped.txt
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net472/PublicAPI.Unshipped.txt
@@ -4,6 +4,8 @@ Microsoft.Agents.AI.Workflows.Declarative.DeclarativeWorkflowOptions.AllowedEnvi
Microsoft.Agents.AI.Workflows.Declarative.DeclarativeWorkflowOptions.AllowProcessEnvironmentVariableFallback.get -> bool
Microsoft.Agents.AI.Workflows.Declarative.DeclarativeWorkflowOptions.AllowProcessEnvironmentVariableFallback.init -> void
Microsoft.Agents.AI.Workflows.Declarative.Events.ExternalInputResponse.RequestId.get -> string?
+Microsoft.Agents.AI.Workflows.Declarative.IWorkflowScopedMcpToolHandler
+Microsoft.Agents.AI.Workflows.Declarative.IWorkflowScopedMcpToolHandler.InvokeToolInWorkflowSessionAsync(string! serverUrl, string? serverLabel, string! toolName, System.Collections.Generic.IDictionary? arguments, System.Collections.Generic.IDictionary? headers, string? connectionName, string! workflowSessionId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.ConvertValueWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, Microsoft.Agents.AI.Workflows.Declarative.Kit.VariableType! targetType, string! key, string? scopeName = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask!>
static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.EvaluateValueWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, string! expression, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask!>
static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.FormatTemplateWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, string! line) -> System.Threading.Tasks.ValueTask!>
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net8.0/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net8.0/PublicAPI.Unshipped.txt
index 8af74cb2369..5bee0f03310 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net8.0/PublicAPI.Unshipped.txt
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net8.0/PublicAPI.Unshipped.txt
@@ -4,6 +4,8 @@ Microsoft.Agents.AI.Workflows.Declarative.DeclarativeWorkflowOptions.AllowedEnvi
Microsoft.Agents.AI.Workflows.Declarative.DeclarativeWorkflowOptions.AllowProcessEnvironmentVariableFallback.get -> bool
Microsoft.Agents.AI.Workflows.Declarative.DeclarativeWorkflowOptions.AllowProcessEnvironmentVariableFallback.init -> void
Microsoft.Agents.AI.Workflows.Declarative.Events.ExternalInputResponse.RequestId.get -> string?
+Microsoft.Agents.AI.Workflows.Declarative.IWorkflowScopedMcpToolHandler
+Microsoft.Agents.AI.Workflows.Declarative.IWorkflowScopedMcpToolHandler.InvokeToolInWorkflowSessionAsync(string! serverUrl, string? serverLabel, string! toolName, System.Collections.Generic.IDictionary? arguments, System.Collections.Generic.IDictionary? headers, string? connectionName, string! workflowSessionId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.ConvertValueWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, Microsoft.Agents.AI.Workflows.Declarative.Kit.VariableType! targetType, string! key, string? scopeName = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask!>
static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.EvaluateValueWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, string! expression, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask!>
static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.FormatTemplateWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, string! line) -> System.Threading.Tasks.ValueTask!>
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net9.0/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net9.0/PublicAPI.Unshipped.txt
index 8af74cb2369..5bee0f03310 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net9.0/PublicAPI.Unshipped.txt
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net9.0/PublicAPI.Unshipped.txt
@@ -4,6 +4,8 @@ Microsoft.Agents.AI.Workflows.Declarative.DeclarativeWorkflowOptions.AllowedEnvi
Microsoft.Agents.AI.Workflows.Declarative.DeclarativeWorkflowOptions.AllowProcessEnvironmentVariableFallback.get -> bool
Microsoft.Agents.AI.Workflows.Declarative.DeclarativeWorkflowOptions.AllowProcessEnvironmentVariableFallback.init -> void
Microsoft.Agents.AI.Workflows.Declarative.Events.ExternalInputResponse.RequestId.get -> string?
+Microsoft.Agents.AI.Workflows.Declarative.IWorkflowScopedMcpToolHandler
+Microsoft.Agents.AI.Workflows.Declarative.IWorkflowScopedMcpToolHandler.InvokeToolInWorkflowSessionAsync(string! serverUrl, string? serverLabel, string! toolName, System.Collections.Generic.IDictionary? arguments, System.Collections.Generic.IDictionary? headers, string? connectionName, string! workflowSessionId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.ConvertValueWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, Microsoft.Agents.AI.Workflows.Declarative.Kit.VariableType! targetType, string! key, string? scopeName = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask!>
static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.EvaluateValueWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, string! expression, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask!>
static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.FormatTemplateWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, string! line) -> System.Threading.Tasks.ValueTask!>
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt
index 8af74cb2369..5bee0f03310 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt
@@ -4,6 +4,8 @@ Microsoft.Agents.AI.Workflows.Declarative.DeclarativeWorkflowOptions.AllowedEnvi
Microsoft.Agents.AI.Workflows.Declarative.DeclarativeWorkflowOptions.AllowProcessEnvironmentVariableFallback.get -> bool
Microsoft.Agents.AI.Workflows.Declarative.DeclarativeWorkflowOptions.AllowProcessEnvironmentVariableFallback.init -> void
Microsoft.Agents.AI.Workflows.Declarative.Events.ExternalInputResponse.RequestId.get -> string?
+Microsoft.Agents.AI.Workflows.Declarative.IWorkflowScopedMcpToolHandler
+Microsoft.Agents.AI.Workflows.Declarative.IWorkflowScopedMcpToolHandler.InvokeToolInWorkflowSessionAsync(string! serverUrl, string? serverLabel, string! toolName, System.Collections.Generic.IDictionary? arguments, System.Collections.Generic.IDictionary? headers, string? connectionName, string! workflowSessionId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.ConvertValueWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, Microsoft.Agents.AI.Workflows.Declarative.Kit.VariableType! targetType, string! key, string? scopeName = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask!>
static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.EvaluateValueWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, string! expression, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask!>
static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.FormatTemplateWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, string! line) -> System.Threading.Tasks.ValueTask!>
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/GroupChatManager.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/GroupChatManager.cs
index ab94a9fa8aa..fbda33c132a 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows/GroupChatManager.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/GroupChatManager.cs
@@ -152,7 +152,7 @@ protected virtual ValueTask OnCheckpointRestoredAsync(IWorkflowContext context,
internal async ValueTask CheckpointAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
{
await context.QueueStateUpdateAsync(BaseStateKey, new GroupChatManagerState(this.IterationCount), cancellationToken: cancellationToken).ConfigureAwait(false);
- await this.OnCheckpointingAsync(new PrefixingWorkflowContext(context, SubclassStateKeyPrefix), cancellationToken).ConfigureAwait(false);
+ await this.OnCheckpointingAsync(PrefixingWorkflowContext.Create(context, SubclassStateKeyPrefix), cancellationToken).ConfigureAwait(false);
}
// Root restore entry point invoked by the hosting GroupChatHost. Symmetric to CheckpointAsync.
@@ -160,7 +160,7 @@ internal async ValueTask RestoreCheckpointAsync(IWorkflowContext context, Cancel
{
GroupChatManagerState? state = await context.ReadStateAsync(BaseStateKey, cancellationToken: cancellationToken).ConfigureAwait(false);
this.IterationCount = state?.IterationCount ?? 0;
- await this.OnCheckpointRestoredAsync(new PrefixingWorkflowContext(context, SubclassStateKeyPrefix), cancellationToken).ConfigureAwait(false);
+ await this.OnCheckpointRestoredAsync(PrefixingWorkflowContext.Create(context, SubclassStateKeyPrefix), cancellationToken).ConfigureAwait(false);
}
}
@@ -169,11 +169,16 @@ internal sealed record GroupChatManagerState(int IterationCount);
// IWorkflowContext decorator that prepends a fixed prefix to every state key passed through it.
// All non-state members (events, message sending, output yielding, halt requests, trace context,
// and runtime characteristics) delegate directly to the wrapped context.
-internal sealed class PrefixingWorkflowContext(IWorkflowContext inner, string prefix) : IWorkflowContext
+internal class PrefixingWorkflowContext(IWorkflowContext inner, string prefix) : IWorkflowContext
{
private readonly IWorkflowContext _inner = Throw.IfNull(inner);
private readonly string _prefix = Throw.IfNullOrEmpty(prefix);
+ public static IWorkflowContext Create(IWorkflowContext inner, string prefix) =>
+ inner is IWorkflowSessionContext sessionContext
+ ? new PrefixingWorkflowSessionContext(inner, prefix, sessionContext)
+ : new PrefixingWorkflowContext(inner, prefix);
+
public IReadOnlyDictionary? TraceContext => this._inner.TraceContext;
public bool ConcurrentRunsEnabled => this._inner.ConcurrentRunsEnabled;
@@ -222,3 +227,11 @@ public async ValueTask QueueClearScopeAsync(string? scopeName = null, Cancellati
private string Wrap(string key) => this._prefix + Throw.IfNullOrEmpty(key);
}
+
+internal sealed class PrefixingWorkflowSessionContext(
+ IWorkflowContext inner,
+ string prefix,
+ IWorkflowSessionContext sessionContext) : PrefixingWorkflowContext(inner, prefix), IWorkflowSessionContext
+{
+ public string SessionId => sessionContext.SessionId;
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/IWorkflowContext.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/IWorkflowContext.cs
index b8b35fffd64..68ff864085f 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows/IWorkflowContext.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/IWorkflowContext.cs
@@ -195,3 +195,18 @@ ValueTask ReadOrInitStateAsync(string key, Func initialStateFactory, Ca
///
bool ConcurrentRunsEnabled { get; }
}
+
+///
+/// Exposes the framework-owned identifier for a workflow session.
+///
+///
+/// Runtime services can use this identifier to scope stateful resources to one
+/// workflow session without accepting caller-provided cache partition keys.
+///
+public interface IWorkflowSessionContext
+{
+ ///
+ /// Gets the framework-owned workflow session identifier.
+ ///
+ string SessionId { get; }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunner.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunner.cs
index 69b6c1e9bcc..c0a2e075ae1 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunner.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunner.cs
@@ -31,7 +31,14 @@ public static InProcessRunner CreateTopLevelRunner(Workflow workflow, ICheckpoin
knownValidInputTypes: knownValidInputTypes);
}
- public static InProcessRunner CreateSubworkflowRunner(Workflow workflow, ICheckpointManager? checkpointManager, string? sessionId = null, object? existingOwnerSignoff = null, bool enableConcurrentRuns = false, IEnumerable? knownValidInputTypes = null)
+ public static InProcessRunner CreateSubworkflowRunner(
+ Workflow workflow,
+ ICheckpointManager? checkpointManager,
+ string? sessionId = null,
+ object? existingOwnerSignoff = null,
+ bool enableConcurrentRuns = false,
+ IEnumerable? knownValidInputTypes = null,
+ string? workflowSessionId = null)
{
return new InProcessRunner(workflow,
checkpointManager,
@@ -39,10 +46,19 @@ public static InProcessRunner CreateSubworkflowRunner(Workflow workflow, ICheckp
existingOwnerSignoff: existingOwnerSignoff,
enableConcurrentRuns: enableConcurrentRuns,
knownValidInputTypes: knownValidInputTypes,
- subworkflow: true);
+ subworkflow: true,
+ workflowSessionId: workflowSessionId);
}
- private InProcessRunner(Workflow workflow, ICheckpointManager? checkpointManager, string? sessionId = null, object? existingOwnerSignoff = null, bool subworkflow = false, bool enableConcurrentRuns = false, IEnumerable? knownValidInputTypes = null)
+ private InProcessRunner(
+ Workflow workflow,
+ ICheckpointManager? checkpointManager,
+ string? sessionId = null,
+ object? existingOwnerSignoff = null,
+ bool subworkflow = false,
+ bool enableConcurrentRuns = false,
+ IEnumerable? knownValidInputTypes = null,
+ string? workflowSessionId = null)
{
if (enableConcurrentRuns && !workflow.AllowConcurrent)
{
@@ -54,7 +70,16 @@ private InProcessRunner(Workflow workflow, ICheckpointManager? checkpointManager
this.StartExecutorId = workflow.StartExecutorId;
this.Workflow = Throw.IfNull(workflow);
- this.RunContext = new InProcessRunnerContext(workflow, this.SessionId, checkpointingEnabled: checkpointManager != null, this.OutgoingEvents, this.StepTracer, existingOwnerSignoff, subworkflow, enableConcurrentRuns);
+ this.RunContext = new InProcessRunnerContext(
+ workflow,
+ this.SessionId,
+ checkpointingEnabled: checkpointManager != null,
+ this.OutgoingEvents,
+ this.StepTracer,
+ existingOwnerSignoff,
+ subworkflow,
+ enableConcurrentRuns,
+ workflowSessionId: workflowSessionId);
this.CheckpointManager = checkpointManager;
this._knownValidInputTypes = knownValidInputTypes != null
@@ -65,6 +90,8 @@ private InProcessRunner(Workflow workflow, ICheckpointManager? checkpointManager
///
public string SessionId { get; }
+ internal string WorkflowSessionId => this.RunContext.WorkflowSessionId;
+
///
public string StartExecutorId { get; }
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunnerContext.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunnerContext.cs
index 353c38f25c9..aa8bf398592 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunnerContext.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunnerContext.cs
@@ -47,7 +47,8 @@ public InProcessRunnerContext(
object? existingOwnershipSignoff = null,
bool subworkflow = false,
bool enableConcurrentRuns = false,
- ILogger? logger = null)
+ ILogger? logger = null,
+ string? workflowSessionId = null)
{
if (enableConcurrentRuns)
{
@@ -62,6 +63,7 @@ public InProcessRunnerContext(
this._workflow = workflow;
this._sessionId = sessionId;
+ this.WorkflowSessionId = workflowSessionId ?? sessionId;
this._edgeMap = new(this, this._workflow, stepTracer);
this._outputFilter = new(workflow);
@@ -72,6 +74,8 @@ public InProcessRunnerContext(
}
public WorkflowTelemetryContext TelemetryContext => this._workflow.TelemetryContext;
+ internal string WorkflowSessionId { get; }
+
public IExternalRequestSink RegisterPort(string executorId, RequestPort port)
{
if (!this._edgeMap.TryRegisterPort(this, executorId, port))
@@ -349,8 +353,10 @@ public IExternalRequestSink RegisterPort(RequestPort port)
private sealed class BoundWorkflowContext(
InProcessRunnerContext RunnerContext,
string ExecutorId,
- Dictionary? traceContext) : IWorkflowContext
+ Dictionary? traceContext) : IWorkflowContext, IWorkflowSessionContext
{
+ public string SessionId => RunnerContext.WorkflowSessionId;
+
public ValueTask AddEventAsync(WorkflowEvent workflowEvent, CancellationToken cancellationToken = default) => RunnerContext.AddEventAsync(workflowEvent, cancellationToken);
public ValueTask SendMessageAsync(object message, string? targetId = null, CancellationToken cancellationToken = default)
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/PublicAPI/net10.0/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI.Workflows/PublicAPI/net10.0/PublicAPI.Unshipped.txt
index ab058de62d4..724a27a74a4 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows/PublicAPI/net10.0/PublicAPI.Unshipped.txt
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/PublicAPI/net10.0/PublicAPI.Unshipped.txt
@@ -1 +1,3 @@
#nullable enable
+Microsoft.Agents.AI.Workflows.IWorkflowSessionContext
+Microsoft.Agents.AI.Workflows.IWorkflowSessionContext.SessionId.get -> string!
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/PublicAPI/net472/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI.Workflows/PublicAPI/net472/PublicAPI.Unshipped.txt
index ab058de62d4..724a27a74a4 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows/PublicAPI/net472/PublicAPI.Unshipped.txt
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/PublicAPI/net472/PublicAPI.Unshipped.txt
@@ -1 +1,3 @@
#nullable enable
+Microsoft.Agents.AI.Workflows.IWorkflowSessionContext
+Microsoft.Agents.AI.Workflows.IWorkflowSessionContext.SessionId.get -> string!
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/PublicAPI/net8.0/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI.Workflows/PublicAPI/net8.0/PublicAPI.Unshipped.txt
index ab058de62d4..724a27a74a4 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows/PublicAPI/net8.0/PublicAPI.Unshipped.txt
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/PublicAPI/net8.0/PublicAPI.Unshipped.txt
@@ -1 +1,3 @@
#nullable enable
+Microsoft.Agents.AI.Workflows.IWorkflowSessionContext
+Microsoft.Agents.AI.Workflows.IWorkflowSessionContext.SessionId.get -> string!
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/PublicAPI/net9.0/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI.Workflows/PublicAPI/net9.0/PublicAPI.Unshipped.txt
index ab058de62d4..724a27a74a4 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows/PublicAPI/net9.0/PublicAPI.Unshipped.txt
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/PublicAPI/net9.0/PublicAPI.Unshipped.txt
@@ -1 +1,3 @@
#nullable enable
+Microsoft.Agents.AI.Workflows.IWorkflowSessionContext
+Microsoft.Agents.AI.Workflows.IWorkflowSessionContext.SessionId.get -> string!
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI.Workflows/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt
index ab058de62d4..724a27a74a4 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt
@@ -1 +1,3 @@
#nullable enable
+Microsoft.Agents.AI.Workflows.IWorkflowSessionContext
+Microsoft.Agents.AI.Workflows.IWorkflowSessionContext.SessionId.get -> string!
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/WorkflowHostExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/WorkflowHostExecutor.cs
index 58e3a9e5239..a166d8b0e6b 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/WorkflowHostExecutor.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/WorkflowHostExecutor.cs
@@ -20,6 +20,7 @@ internal class WorkflowHostExecutor : Executor, IAsyncDisposable
private readonly Workflow _workflow;
private readonly ProtocolDescriptor _workflowProtocol;
private readonly object _ownershipToken;
+ private string? _workflowSessionId;
private InProcessRunner? _activeRunner;
private InMemoryCheckpointManager? _checkpointManager;
@@ -56,6 +57,7 @@ protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBui
private async ValueTask QueueExternalMessageAsync(PortableValue portableValue, IWorkflowContext context, CancellationToken cancellationToken)
{
+ this.SetWorkflowSessionId(context);
if (portableValue.Is(out ExternalResponse? response))
{
response = this.CheckAndUnqualifyResponse(response);
@@ -95,7 +97,8 @@ internal async ValueTask EnsureRunnerAsync()
this._checkpointManager,
this._sessionId,
this._ownershipToken,
- this.JoinContext.ConcurrentRunsEnabled);
+ this.JoinContext.ConcurrentRunsEnabled,
+ workflowSessionId: this._workflowSessionId);
}
return this._activeRunner;
@@ -268,6 +271,7 @@ await context.QueueStateUpdateAsync(PendingResponsePortsStateKey,
protected internal override async ValueTask OnCheckpointRestoredAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
{
await base.OnCheckpointRestoredAsync(context, cancellationToken).ConfigureAwait(false);
+ this.SetWorkflowSessionId(context);
InMemoryCheckpointManager manager = await context.ReadStateAsync(CheckpointManagerStateKey, cancellationToken: cancellationToken).ConfigureAwait(false) ?? new();
if (this._checkpointManager == manager)
@@ -293,6 +297,14 @@ await context.ReadStateAsync>(PendingRespons
await this.EnsureRunSendMessageAsync(resume: true, cancellationToken: cancellationToken).ConfigureAwait(false);
}
+ private void SetWorkflowSessionId(IWorkflowContext context)
+ {
+ string parentSessionId = context is IWorkflowSessionContext sessionContext
+ ? sessionContext.SessionId
+ : this._sessionId;
+ this._workflowSessionId ??= SubworkflowBinding.CreateSubworkflowSessionId(parentSessionId, this.Id);
+ }
+
private async ValueTask ResetAsync()
{
if (this._run != null)
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/SubworkflowBinding.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/SubworkflowBinding.cs
index 11f7cf493cf..9519af12414 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows/SubworkflowBinding.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/SubworkflowBinding.cs
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
+using System.Globalization;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Specialized;
using Microsoft.Shared.Diagnostics;
@@ -35,6 +36,13 @@ async ValueTask InitHostExecutorAsync(string sessionId)
}
}
+ internal static string CreateSubworkflowSessionId(string parentSessionId, string subworkflowId) =>
+ string.Concat(
+ parentSessionId.Length.ToString(CultureInfo.InvariantCulture),
+ ":",
+ parentSessionId,
+ subworkflowId);
+
///
public override bool IsSharedInstance => false;
diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.Mcp.UnitTests/DefaultMcpToolHandlerLifetimeTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.Mcp.UnitTests/DefaultMcpToolHandlerLifetimeTests.cs
index 3a481fcd0e6..1e313753575 100644
--- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.Mcp.UnitTests/DefaultMcpToolHandlerLifetimeTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.Mcp.UnitTests/DefaultMcpToolHandlerLifetimeTests.cs
@@ -104,6 +104,201 @@ public async Task NoProvider_ReusesSessionUntilHandlerDisposalAsync()
transport.Protected().Verify("Dispose", Times.AtLeastOnce(), ItExpr.Is(disposing => disposing));
}
+ [Fact]
+ public async Task NoProvider_SeparateWorkflowSessions_UseSeparateCachedSessionsAsync()
+ {
+ // Arrange
+ ProtocolStub stub = new();
+ await using DefaultMcpToolHandler handler = new(null, stub.CreateMessageHandler);
+ using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(10));
+
+ // Act
+ await InvokeScopedAsync(handler, "workflow-a", "ping", timeout.Token);
+ await InvokeScopedAsync(handler, "workflow-b", "ping", timeout.Token);
+ await InvokeScopedAsync(handler, "workflow-a", "ping", timeout.Token);
+
+ // Assert
+ Assert.Equal(2, stub.Initializations);
+ Assert.Equal(0, stub.Terminations);
+ }
+
+ [Fact]
+ public async Task DrainCleanupAsync_Cancellation_DrainsAllCleanupAndCompletionTasksAsync()
+ {
+ // Arrange
+ TaskCompletionSource firstDisposed = new(TaskCreationOptions.RunContinuationsAsynchronously);
+ TaskCompletionSource secondDisposed = new(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ async Task CancelledCleanupAsync()
+ {
+ try
+ {
+ await Task.Yield();
+ throw new OperationCanceledException("session cleanup cancelled");
+ }
+ finally
+ {
+ firstDisposed.TrySetResult(true);
+ }
+ }
+
+ async Task SuccessfulCleanupAsync()
+ {
+ await Task.Yield();
+ secondDisposed.TrySetResult(true);
+ }
+
+ // Act
+ await Assert.ThrowsAnyAsync(() => DefaultMcpToolHandler.DrainCleanupAsync(
+ [CancelledCleanupAsync(), SuccessfulCleanupAsync()],
+ [firstDisposed.Task, secondDisposed.Task]));
+
+ // Assert
+ Assert.True(await firstDisposed.Task);
+ Assert.True(await secondDisposed.Task);
+ }
+
+ [Fact]
+ public async Task NoProvider_CacheEviction_DisposesLeastRecentlyUsedSessionAsync()
+ {
+ // Arrange
+ ProtocolStub stub = new();
+ await using DefaultMcpToolHandler handler = new(null, stub.CreateMessageHandler, clientCacheMaxSize: 2);
+ using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(10));
+
+ // Act
+ await InvokeScopedAsync(handler, "workflow-a", "ping", timeout.Token);
+ await InvokeScopedAsync(handler, "workflow-b", "ping", timeout.Token);
+ await InvokeScopedAsync(handler, "workflow-c", "ping", timeout.Token);
+
+ // Assert
+ Assert.Equal(3, stub.Initializations);
+ Assert.Equal(1, stub.Terminations);
+ }
+
+ [Fact]
+ public async Task NoProvider_CacheEviction_DisposesUnusedOwnedHttpClientAsync()
+ {
+ // Arrange
+ ProtocolStub stub = new();
+ await using DefaultMcpToolHandler handler = new(null, stub.CreateMessageHandler, clientCacheMaxSize: 1);
+ using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(10));
+
+ // Act
+ await handler.InvokeToolInWorkflowSessionAsync(
+ "https://first.example/api", null, "ping", null, null, null, "workflow-a", timeout.Token);
+ await handler.InvokeToolInWorkflowSessionAsync(
+ "https://second.example/api", null, "ping", null, null, null, "workflow-b", timeout.Token);
+
+ // Assert
+ Assert.Equal(2, stub.Initializations);
+ Assert.Equal(1, stub.Terminations);
+ Assert.Equal(2, stub.Handlers.Count);
+ stub.Handlers[0].Protected().Verify(
+ "Dispose", Times.AtLeastOnce(), ItExpr.Is(disposing => disposing));
+ stub.Handlers[1].Protected().Verify(
+ "Dispose", Times.Never(), ItExpr.Is(disposing => disposing));
+ }
+
+ [Fact]
+ public async Task NoProvider_CacheEviction_DefersDisposalUntilActiveInvocationCompletesAsync()
+ {
+ // Arrange
+ ProtocolStub stub = new();
+ using SemaphoreSlim firstStarted = new(0);
+ using SemaphoreSlim releaseFirst = new(0);
+ int operations = 0;
+ stub.BeforeOperationAsync = async token =>
+ {
+ if (Interlocked.Increment(ref operations) == 1)
+ {
+ firstStarted.Release();
+ await releaseFirst.WaitAsync(token);
+ }
+ };
+ DefaultMcpToolHandler handler = new(null, stub.CreateMessageHandler, clientCacheMaxSize: 1);
+ using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(10));
+
+ // Act
+ Task first = InvokeScopedAsync(handler, "workflow-a", "ping", timeout.Token);
+ await firstStarted.WaitAsync(timeout.Token);
+ await InvokeScopedAsync(handler, "workflow-b", "ping", timeout.Token);
+
+ // Assert
+ Assert.Equal(0, stub.Terminations);
+ Task disposal = handler.DisposeAsync().AsTask();
+ Assert.False(disposal.IsCompleted);
+ releaseFirst.Release();
+ await first;
+ await disposal;
+ Assert.Equal(2, stub.Terminations);
+ }
+
+ [Fact]
+ public async Task NoProvider_ConcurrentWorkflowSessionCreations_AreBoundedByCacheSizeAsync()
+ {
+ // Arrange
+ ProtocolStub stub = new();
+ using SemaphoreSlim initializationsStarted = new(0);
+ using SemaphoreSlim releaseInitializations = new(0);
+ stub.BeforeInitializationAsync = async token =>
+ {
+ initializationsStarted.Release();
+ await releaseInitializations.WaitAsync(token);
+ };
+ await using DefaultMcpToolHandler handler = new(null, stub.CreateMessageHandler, clientCacheMaxSize: 2);
+ using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(10));
+ using CancellationTokenSource concurrencyTimeout = new(TimeSpan.FromSeconds(2));
+
+ // Act
+ Task first = InvokeScopedAsync(handler, "workflow-a", "ping", timeout.Token);
+ await initializationsStarted.WaitAsync(timeout.Token);
+ Task second = InvokeScopedAsync(handler, "workflow-b", "ping", timeout.Token);
+ await initializationsStarted.WaitAsync(concurrencyTimeout.Token);
+ Task third = InvokeScopedAsync(handler, "workflow-c", "ping", timeout.Token);
+ using CancellationTokenSource gateTimeout = new(TimeSpan.FromMilliseconds(250));
+ await Assert.ThrowsAnyAsync(() => initializationsStarted.WaitAsync(gateTimeout.Token));
+ releaseInitializations.Release(2);
+ await Task.WhenAll(first, second);
+ await initializationsStarted.WaitAsync(timeout.Token);
+ releaseInitializations.Release();
+ await third;
+
+ // Assert
+ Assert.Equal(3, stub.Initializations);
+ Assert.Equal(1, stub.Terminations);
+ }
+
+ [Fact]
+ public async Task NoProvider_DisposalDuringFailedCreation_PreservesCreationFailureAsync()
+ {
+ // Arrange
+ ProtocolStub stub = new() { FailInitialization = true };
+ using SemaphoreSlim initializationStarted = new(0);
+ using SemaphoreSlim releaseInitialization = new(0);
+ stub.BeforeInitializationAsync = async token =>
+ {
+ initializationStarted.Release();
+ await releaseInitialization.WaitAsync(token);
+ };
+ DefaultMcpToolHandler handler = new(null, stub.CreateMessageHandler);
+ using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(10));
+
+ // Act
+ Task invocation =
+ InvokeScopedAsync(handler, "workflow-a", "ping", timeout.Token);
+ await initializationStarted.WaitAsync(timeout.Token);
+ Task disposal = handler.DisposeAsync().AsTask();
+ releaseInitialization.Release();
+
+ // Assert
+ await Assert.ThrowsAsync(() => invocation);
+ await disposal;
+ Assert.Single(stub.Handlers);
+ stub.Handlers[0].Protected().Verify(
+ "Dispose", Times.AtLeastOnce(), ItExpr.Is(disposing => disposing));
+ }
+
[Fact]
public async Task NoProvider_DifferentConnectionNames_UseSeparateCachedSessionsAsync()
{
@@ -185,6 +380,91 @@ public async Task Provider_ConcurrentInvocations_DoNotCoalesceOrSerializeSession
Assert.Equal(2, stub.Terminations);
}
+ [Fact]
+ public async Task NoProvider_ConcurrentSameWorkflowInvocations_CoalesceSessionCreationAsync()
+ {
+ // Arrange
+ ProtocolStub stub = new();
+ await using DefaultMcpToolHandler handler = new(null, stub.CreateMessageHandler);
+ using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(10));
+
+ // Act
+ await Task.WhenAll(
+ InvokeScopedAsync(handler, "workflow-a", "ping", timeout.Token),
+ InvokeScopedAsync(handler, "workflow-a", "ping", timeout.Token));
+
+ // Assert
+ Assert.Equal(1, stub.Initializations);
+ }
+
+ [Fact]
+ public async Task NoProvider_CancelledWaiter_DoesNotWaitForSharedCreationAsync()
+ {
+ // Arrange
+ ProtocolStub stub = new();
+ using SemaphoreSlim initializationStarted = new(0);
+ using SemaphoreSlim releaseInitialization = new(0);
+ stub.BeforeInitializationAsync = async token =>
+ {
+ initializationStarted.Release();
+ await releaseInitialization.WaitAsync(token);
+ };
+ await using DefaultMcpToolHandler handler = new(null, stub.CreateMessageHandler);
+ using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(10));
+ using CancellationTokenSource waiterCancellation = new();
+
+ // Act
+ Task creator = InvokeScopedAsync(handler, "workflow-a", "ping", timeout.Token);
+ await initializationStarted.WaitAsync(timeout.Token);
+ Task waiter = InvokeScopedAsync(handler, "workflow-a", "ping", waiterCancellation.Token);
+ await Task.Yield();
+ waiterCancellation.Cancel();
+ Task completed = await Task.WhenAny(waiter, Task.Delay(TimeSpan.FromSeconds(1), timeout.Token));
+ releaseInitialization.Release();
+ await creator;
+
+ // Assert
+ Assert.Same(waiter, completed);
+ await Assert.ThrowsAnyAsync(() => waiter);
+ Assert.Equal(1, stub.Initializations);
+ }
+
+ [Fact]
+ public async Task NoProvider_CancelledCreator_DoesNotCancelSharedWaiterAsync()
+ {
+ // Arrange
+ ProtocolStub stub = new();
+ using SemaphoreSlim initializationStarted = new(0);
+ int initializationAttempts = 0;
+ stub.BeforeInitializationAsync = async token =>
+ {
+ initializationStarted.Release();
+ if (Interlocked.Increment(ref initializationAttempts) == 1)
+ {
+ await Task.Delay(Timeout.Infinite, token);
+ }
+ };
+ await using DefaultMcpToolHandler handler = new(null, stub.CreateMessageHandler);
+ using CancellationTokenSource creatorCancellation = new();
+ using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(10));
+
+ // Act
+ Task creator =
+ InvokeScopedAsync(handler, "workflow-a", "ping", creatorCancellation.Token);
+ await initializationStarted.WaitAsync(timeout.Token);
+ Task waiter = InvokeScopedAsync(handler, "workflow-a", "ping", timeout.Token);
+ await Task.Yield();
+ creatorCancellation.Cancel();
+ await Assert.ThrowsAnyAsync(() => creator);
+ await initializationStarted.WaitAsync(timeout.Token);
+ McpServerToolResultContent result = await waiter;
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.Equal(2, initializationAttempts);
+ Assert.Equal(1, stub.Initializations);
+ }
+
[Fact]
public async Task Provider_OperationFailure_DisposesSessionAndPreservesCallerClientAsync()
{
@@ -560,6 +840,11 @@ private static Task InvokeAsync(
DefaultMcpToolHandler handler, string toolName, CancellationToken cancellationToken) =>
handler.InvokeToolAsync("https://mcp.example/api", null, toolName, null, null, null, cancellationToken);
+ private static Task InvokeScopedAsync(
+ DefaultMcpToolHandler handler, string workflowSessionId, string toolName, CancellationToken cancellationToken) =>
+ handler.InvokeToolInWorkflowSessionAsync(
+ "https://mcp.example/api", null, toolName, null, null, null, workflowSessionId, cancellationToken);
+
private sealed class ProtocolStub
{
private int _initializations;
@@ -569,6 +854,7 @@ private sealed class ProtocolStub
public int Terminations => this._terminations;
public List> Handlers { get; } = [];
public Func? BeforeOperationAsync { get; set; }
+ public Func? BeforeInitializationAsync { get; set; }
public bool FailInitialization { get; set; }
public bool FailOperation { get; set; }
public bool FailTransportDisposal { get; set; }
@@ -629,6 +915,11 @@ private async Task SendAsync(HttpRequestMessage request, Ca
string? sessionId = null;
if (method == "initialize")
{
+ if (this.BeforeInitializationAsync is not null)
+ {
+ await this.BeforeInitializationAsync(cancellationToken);
+ }
+
if (this.FailInitialization)
{
return EmptyResponse(HttpStatusCode.BadRequest, request);
diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.Mcp.UnitTests/DefaultMcpToolHandlerTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.Mcp.UnitTests/DefaultMcpToolHandlerTests.cs
index 610f7393eda..a6e57b63193 100644
--- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.Mcp.UnitTests/DefaultMcpToolHandlerTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.Mcp.UnitTests/DefaultMcpToolHandlerTests.cs
@@ -418,7 +418,7 @@ public void ComputeHeadersHash_DifferentHeaders_ReturnsDifferentHash()
// (InvokeToolAsync against a fake server) doesn't surface cache-hit behavior
// without standing up a real MCP server — McpClient.CreateAsync fails before
// _clients[key] = newClient runs, so nothing ever gets cached.
- // Tuple equality on the returned 4-tuple verifies that the dimensions
+ // Tuple equality on the returned 5-tuple verifies that the dimensions
// collectively discriminate cache entries.
[Fact]
@@ -428,19 +428,32 @@ public void BuildCacheKey_SameInputs_ReturnsEqualKeys()
Dictionary headers = new() { ["Authorization"] = "Bearer token" };
// Act
- var key1 = DefaultMcpToolHandler.BuildCacheKey("http://localhost/mcp", "label", "conn", headers);
- var key2 = DefaultMcpToolHandler.BuildCacheKey("http://localhost/mcp", "label", "conn", headers);
+ var key1 = DefaultMcpToolHandler.BuildCacheKey("workflow", "http://localhost/mcp", "label", "conn", headers);
+ var key2 = DefaultMcpToolHandler.BuildCacheKey("workflow", "http://localhost/mcp", "label", "conn", headers);
// Assert
Assert.Equal(key2, key1);
}
+ [Fact]
+ public void BuildCacheKey_DifferentWorkflowSession_ReturnsDifferentKeys()
+ {
+ // Act
+ var key1 = DefaultMcpToolHandler.BuildCacheKey("workflow-a", "http://localhost/mcp", "label", "conn", null);
+ var key2 = DefaultMcpToolHandler.BuildCacheKey("workflow-b", "http://localhost/mcp", "label", "conn", null);
+
+ // Assert
+ Assert.NotEqual(key2, key1);
+ Assert.Equal("workflow-a", key1.WorkflowSession);
+ Assert.Equal("workflow-b", key2.WorkflowSession);
+ }
+
[Fact]
public void BuildCacheKey_DifferentConnectionName_ReturnsDifferentKeys()
{
// Act
- var key1 = DefaultMcpToolHandler.BuildCacheKey("http://localhost/mcp", "label", "connection-a", null);
- var key2 = DefaultMcpToolHandler.BuildCacheKey("http://localhost/mcp", "label", "connection-b", null);
+ var key1 = DefaultMcpToolHandler.BuildCacheKey("workflow", "http://localhost/mcp", "label", "connection-a", null);
+ var key2 = DefaultMcpToolHandler.BuildCacheKey("workflow", "http://localhost/mcp", "label", "connection-b", null);
// Assert
Assert.NotEqual(key2, key1);
@@ -452,8 +465,8 @@ public void BuildCacheKey_DifferentConnectionName_ReturnsDifferentKeys()
public void BuildCacheKey_DifferentServerLabel_ReturnsDifferentKeys()
{
// Act
- var key1 = DefaultMcpToolHandler.BuildCacheKey("http://localhost/mcp", "label-a", null, null);
- var key2 = DefaultMcpToolHandler.BuildCacheKey("http://localhost/mcp", "label-b", null, null);
+ var key1 = DefaultMcpToolHandler.BuildCacheKey("workflow", "http://localhost/mcp", "label-a", null, null);
+ var key2 = DefaultMcpToolHandler.BuildCacheKey("workflow", "http://localhost/mcp", "label-b", null, null);
// Assert
Assert.NotEqual(key2, key1);
@@ -466,8 +479,8 @@ public void BuildCacheKey_CaseSensitiveUrlPath_ReturnsDifferentKeys()
{
// Arrange — RFC 3986: URL path is case-sensitive
// Act
- var key1 = DefaultMcpToolHandler.BuildCacheKey("http://localhost/Tools", null, null, null);
- var key2 = DefaultMcpToolHandler.BuildCacheKey("http://localhost/tools", null, null, null);
+ var key1 = DefaultMcpToolHandler.BuildCacheKey("workflow", "http://localhost/Tools", null, null, null);
+ var key2 = DefaultMcpToolHandler.BuildCacheKey("workflow", "http://localhost/tools", null, null, null);
// Assert
Assert.NotEqual(key2, key1);
@@ -481,8 +494,8 @@ public void BuildCacheKey_HeaderValuesCaseSensitive_ReturnsDifferentKeys()
Dictionary headers2 = new() { ["Authorization"] = "Bearer abc" };
// Act
- var key1 = DefaultMcpToolHandler.BuildCacheKey("http://localhost/mcp", null, null, headers1);
- var key2 = DefaultMcpToolHandler.BuildCacheKey("http://localhost/mcp", null, null, headers2);
+ var key1 = DefaultMcpToolHandler.BuildCacheKey("workflow", "http://localhost/mcp", null, null, headers1);
+ var key2 = DefaultMcpToolHandler.BuildCacheKey("workflow", "http://localhost/mcp", null, null, headers2);
// Assert — header value case must propagate into the cache key
Assert.NotEqual(key2, key1);
@@ -493,7 +506,7 @@ public void BuildCacheKey_HeaderValuesCaseSensitive_ReturnsDifferentKeys()
public void BuildCacheKey_NullLabelAndConnection_NormalizesToEmptyString()
{
// Act
- var key = DefaultMcpToolHandler.BuildCacheKey("http://localhost/mcp", null, null, null);
+ var key = DefaultMcpToolHandler.BuildCacheKey("workflow", "http://localhost/mcp", null, null, null);
// Assert — verifies null-safety contract callers rely on
Assert.Equal(string.Empty, key.Label);
diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Kit/IWorkflowContextExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Kit/IWorkflowContextExtensionsTests.cs
index d26a1b6a84b..802f71cf48d 100644
--- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Kit/IWorkflowContextExtensionsTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Kit/IWorkflowContextExtensionsTests.cs
@@ -23,7 +23,7 @@ public async Task FormatTemplateAsync_WithSensitiveValue_ThrowsAsync()
WorkflowFormulaState state = new(RecalcEngineFactory.Create());
state.Set("SOME_SECRET", FormulaValue.New("secret-value"), VariableScopeNames.Environment, SensitivityLevel.Sensitive);
state.Bind();
- DeclarativeWorkflowContext context = new(new Mock().Object, state);
+ DeclarativeWorkflowContext context = await CreateContextAsync(state);
// Act
ValueTask FormatAsync() => context.FormatTemplateAsync("={Env.SOME_SECRET}");
@@ -40,7 +40,7 @@ public async Task FormatTemplateWithSensitivityAsync_WithSensitiveValue_ReturnsS
WorkflowFormulaState state = new(RecalcEngineFactory.Create());
state.Set("SOME_SECRET", FormulaValue.New("secret-value"), VariableScopeNames.Environment, SensitivityLevel.Sensitive);
state.Bind();
- DeclarativeWorkflowContext context = new(new Mock().Object, state);
+ DeclarativeWorkflowContext context = await CreateContextAsync(state);
// Act
EvaluationResult result = await context.FormatTemplateWithSensitivityAsync("={Env.SOME_SECRET}");
@@ -57,7 +57,7 @@ public async Task EvaluateValueAsync_WithSensitiveValue_ThrowsAsync()
WorkflowFormulaState state = new(RecalcEngineFactory.Create());
state.Set(SystemScope.Names.LastMessageText, FormulaValue.New("secret-value"), VariableScopeNames.System, SensitivityLevel.Sensitive);
state.Bind();
- DeclarativeWorkflowContext context = new(new Mock().Object, state);
+ DeclarativeWorkflowContext context = await CreateContextAsync(state);
// Act
ValueTask