.NET: [BREAKING] Migrate MCP long-running task support to the 2026-07-28 Tasks extension (#7774)

* Migrate MCP long-running task support to the 2026-07-28 Tasks extension

* Address PR comments.

* Address PR comments.

* Address PR comments
This commit is contained in:
Peter Ibekwe
2026-08-20 23:26:28 +00:00
committed by GitHub
parent ab0f7d5d08
commit 96560bbf65
24 changed files with 1655 additions and 399 deletions
+4 -3
View File
@@ -45,10 +45,10 @@
<!-- System.* -->
<PackageVersion Include="Microsoft.Bcl.AsyncInterfaces" Version="10.0.11" />
<PackageVersion Include="Microsoft.Bcl.HashCode" Version="6.0.0" />
<PackageVersion Include="Microsoft.Bcl.Memory" Version="10.0.5" />
<PackageVersion Include="Microsoft.Bcl.Memory" Version="10.0.10" />
<PackageVersion Include="System.ClientModel" Version="1.15.0" />
<PackageVersion Include="System.CodeDom" Version="10.0.0" />
<PackageVersion Include="System.Collections.Immutable" Version="10.0.1" />
<PackageVersion Include="System.Collections.Immutable" Version="10.0.10" />
<PackageVersion Include="System.CommandLine" Version="2.0.0-rc.2.25502.107" />
<PackageVersion Include="System.Diagnostics.DiagnosticSource" Version="10.0.11" />
<PackageVersion Include="System.Linq.AsyncEnumerable" Version="10.0.5" />
@@ -120,7 +120,8 @@
<PackageVersion Include="A2A" Version="1.0.0-preview2" />
<PackageVersion Include="A2A.AspNetCore" Version="1.0.0-preview2" />
<!-- MCP -->
<PackageVersion Include="ModelContextProtocol" Version="1.2.0" />
<PackageVersion Include="ModelContextProtocol" Version="2.1.0" />
<PackageVersion Include="ModelContextProtocol.Extensions.Tasks" Version="2.1.0" />
<!-- Hyperlight -->
<PackageVersion Include="Hyperlight.HyperlightSandbox.Api" Version="0.4.0" />
<PackageVersion Include="Hyperlight.HyperlightSandbox.Guest.Python" Version="0.4.0" />
@@ -1303,6 +1303,7 @@ internal static class AgentsSamples
OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"],
MustContain =
[
"MCP 2026-07-28 Tasks extension enabled.",
"=== Transparent long-running MCP task (RunAsync) ===",
"=== Transparent long-running MCP task (RunStreamingAsync) ===",
],
@@ -15,6 +15,7 @@
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
<PackageReference Include="Microsoft.Extensions.Hosting" />
<PackageReference Include="ModelContextProtocol" />
<PackageReference Include="ModelContextProtocol.Extensions.Tasks" />
</ItemGroup>
<ItemGroup>
@@ -5,7 +5,7 @@
// A small MCP server (hosted in this same executable when launched with "--server") exposes
// a single task-supporting tool "AnalyzeDataset" that simulates ~15 seconds of work. The
// client (default mode) connects to it over stdio via Microsoft.Agents.AI.Mcp's
// McpClientTaskExtensions.ListAgentToolsWithTaskSupportAsync, hands the wrapped tools to a
// McpClientTaskExtensions.ListAgentToolsWithTasksAsync, hands the wrapped tools to a
// ChatClientAgent, and exercises both invocation styles:
// * RunAsync — blocks until the agent's final response is ready.
// * RunStreamingAsync — yields response updates as the model produces them; the model
@@ -14,9 +14,9 @@
// tool execution time, not stream-channel latency.
//
// In both cases the wrapper transparently:
// 1. Calls tools/call with task augmentation (CallToolAsTaskAsync)
// 2. Polls tasks/get until terminal (PollTaskUntilCompleteAsync)
// 3. Fetches tasks/result and returns the final result to the function-calling loop
// 1. Calls tools/call with the io.modelcontextprotocol/tasks extension capability
// 2. Accepts either an inline result or a task handle
// 3. Polls tasks/get until the final result is available
//
// No application-level loop or continuation tokens are required in either mode.
@@ -29,8 +29,8 @@ using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using ModelContextProtocol;
using ModelContextProtocol.Client;
using ModelContextProtocol.Extensions.Tasks;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using OpenAI.Chat;
@@ -53,15 +53,9 @@ await using var mcpClient = await McpClient.CreateAsync(new StdioClientTransport
Arguments = [thisAssemblyPath, "--server"],
}));
// Wrap each MCP tool with task-aware behavior. The wrapper inspects the server's
// execution.taskSupport on each tool and, when it is Required, drives the task lifecycle
// transparently within the agent's tool loop. Tools that don't require task semantics are
// returned as-is and invoked inline.
var taskOptions = new McpTaskOptions
{
DefaultTimeToLive = TimeSpan.FromMinutes(5),
};
var mcpTools = await mcpClient.ListAgentToolsWithTaskSupportAsync(taskOptions);
// Wrap each MCP tool with task-aware behavior. Each invocation opts into the Tasks extension;
// a task-capable server may return a task handle, while other servers can return inline.
var mcpTools = await mcpClient.ListAgentToolsWithTasksAsync();
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
@@ -76,6 +70,8 @@ AIAgent agent = new AzureOpenAIClient(
const string Prompt = "Analyze the dataset named 'sales-2025-q1' and summarize the findings.";
Console.WriteLine("MCP 2026-07-28 Tasks extension enabled.");
Console.WriteLine();
Console.WriteLine("=== Transparent long-running MCP task (RunAsync) ===");
Console.WriteLine("Asking the agent to analyze a dataset; the tool takes ~15s to complete.");
Console.WriteLine("RunAsync blocks while the wrapper polls the task to completion.");
@@ -117,12 +113,10 @@ static async Task RunMcpServerAsync()
builder.Logging.AddConsole(o => o.LogToStandardErrorThreshold = LogLevel.Trace);
builder.Services.AddMcpServer(o =>
{
o.TaskStore = new InMemoryMcpTaskStore();
o.ServerInfo = new Implementation { Name = "DatasetAnalyzer", Version = "1.0.0" };
})
o.ServerInfo = new Implementation { Name = "DatasetAnalyzer", Version = "1.0.0" })
.WithStdioServerTransport()
.WithTools<DatasetAnalysisTools>();
.WithTools<DatasetAnalysisTools>()
.WithTasks(new InMemoryMcpTaskStore());
await builder.Build().RunAsync();
}
@@ -132,7 +126,7 @@ static async Task RunMcpServerAsync()
internal sealed class DatasetAnalysisTools
#pragma warning restore CA1812
{
[McpServerTool(Name = "AnalyzeDataset", TaskSupport = ToolTaskSupport.Required)]
[McpServerTool(Name = "AnalyzeDataset")]
[Description("Analyze a tabular dataset and return summary statistics. This tool simulates a long-running analytic job (~15 seconds).")]
public static async Task<string> AnalyzeDatasetAsync(
[Description("The dataset identifier, e.g. 'sales-2025-q1'.")] string datasetName,
@@ -1,19 +1,23 @@
# Agent with MCP long-running task (transparent polling)
# Agent with MCP Tasks extension (transparent polling)
This sample demonstrates Microsoft Agent Framework's MCP long-running task support: an agent invokes an MCP tool whose execution takes too long for a single request/response cycle, and the framework polls it to completion behind the function-calling loop. From the agent's perspective the tool simply returns its result.
This sample demonstrates Microsoft Agent Framework's support for the MCP 2026-07-28 Tasks extension: an agent invokes an MCP tool whose execution takes too long for a single request/response cycle, and the framework polls it to completion behind the function-calling loop. From the agent's perspective the tool simply returns its result.
## What this sample shows
- Using `McpClient.ListAgentToolsWithTaskSupportAsync(...)` (in `Microsoft.Agents.AI.Mcp`) to wrap MCP tools with task-aware behavior.
- Configuring `McpTaskOptions.DefaultTimeToLive` to bound the server-side task.
- Hosting a small MCP server (in this same executable, launched with `--server`) that advertises `execution.taskSupport=required` on a tool that sleeps for ~15 seconds.
- Using `McpClient.ListAgentToolsWithTasksAsync(...)` (in `Microsoft.Agents.AI.Mcp`) to wrap MCP tools with task-aware behavior.
- Hosting a small MCP server (in this same executable, launched with `--server`) that enables `io.modelcontextprotocol/tasks` with `WithTasks(...)` and exposes a tool that sleeps for ~15 seconds.
- Allowing the server to return either an inline result or a task handle after the client opts into the extension.
- No application-level polling, continuation tokens, or `AllowBackgroundResponses` flag are required.
The decorator drives the lifecycle internally:
1. `tools/call` augmented with task metadata (`CallToolAsTaskAsync`)
2. `tasks/get` polled until terminal (`PollTaskUntilCompleteAsync`)
3. `tasks/result` retrieved (`GetTaskResultAsync`) and returned to the function-calling loop
1. `tools/call` includes the Tasks extension capability.
2. The server returns either the ordinary tool result or a task handle.
3. `tasks/get` is polled until it carries the final result, which is returned to the function-calling loop.
The transparent adapter retains the created task handle while it polls. By default, cancelling the local invocation also sends a best-effort `tasks/cancel` so abandoned server work can stop cooperatively. Set `McpTaskOptions.CancelRemoteTaskOnLocalCancellation` to `false` when server work should continue independently after the caller stops waiting.
The adapter also rejects unusable server polling intervals and bounds unique mid-flight input requests. If either safety limit is exceeded while the task may still be active, the adapter fails the invocation and sends a best-effort `tasks/cancel`. `McpTaskOptions` can adjust the remote-cancellation timeout and accepted polling-interval range when deployment requirements differ from the defaults.
The sample exercises both invocation styles against the same wrapper:
@@ -10,6 +10,7 @@ using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Extensions.Logging;
using ModelContextProtocol.Authentication;
using ModelContextProtocol.Client;
using OpenAI.Chat;
@@ -39,7 +40,7 @@ var transport = new HttpClientTransport(new()
ClientName = "ProtectedMcpClient",
},
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
AuthorizationCallbackHandler = HandleAuthorizationCallbackAsync,
}
}, httpClient, consoleLoggerFactory);
@@ -63,12 +64,14 @@ Console.WriteLine(await agent.RunAsync("Get current weather alerts for New York?
// Handles the OAuth authorization URL by starting a local HTTP server and opening a browser.
// This implementation demonstrates how SDK consumers can provide their own authorization flow.
static async Task<string?> HandleAuthorizationUrlAsync(Uri authorizationUrl, Uri redirectUri, CancellationToken cancellationToken)
static async Task<AuthorizationResult?> HandleAuthorizationCallbackAsync(
AuthorizationCallbackContext callbackContext,
CancellationToken cancellationToken)
{
Console.WriteLine("Starting OAuth authorization flow...");
Console.WriteLine($"Opening browser to: {authorizationUrl}");
Console.WriteLine($"Opening browser to: {callbackContext.AuthorizationUri}");
var listenerPrefix = redirectUri.GetLeftPart(UriPartial.Authority);
var listenerPrefix = callbackContext.RedirectUri.GetLeftPart(UriPartial.Authority);
if (!listenerPrefix.EndsWith("/", StringComparison.InvariantCultureIgnoreCase))
{
listenerPrefix += "/";
@@ -82,11 +85,13 @@ static async Task<string?> HandleAuthorizationUrlAsync(Uri authorizationUrl, Uri
listener.Start();
Console.WriteLine($"Listening for OAuth callback on: {listenerPrefix}");
OpenBrowser(authorizationUrl);
OpenBrowser(callbackContext.AuthorizationUri);
var context = await listener.GetContextAsync();
var query = HttpUtility.ParseQueryString(context.Request.Url?.Query ?? string.Empty);
var code = query["code"];
var state = query["state"];
var issuer = query["iss"];
var error = query["error"];
const string ResponseHtml = "<html><body><h1>Authentication complete</h1><p>You can close this window now.</p></body></html>";
@@ -102,14 +107,19 @@ static async Task<string?> HandleAuthorizationUrlAsync(Uri authorizationUrl, Uri
return null;
}
if (string.IsNullOrEmpty(code))
if (string.IsNullOrEmpty(code) || string.IsNullOrEmpty(state))
{
Console.WriteLine("No authorization code received");
Console.WriteLine("The authorization response did not contain both code and state.");
return null;
}
Console.WriteLine("Authorization code received successfully.");
return code;
return new AuthorizationResult
{
Code = code,
State = state,
Iss = issuer,
};
}
catch (Exception ex)
{
@@ -23,7 +23,7 @@ Before you begin, ensure you have the following prerequisites:
|[Agent with MCP server tools and authorization](./Agent_MCP_Server_Auth/)|This sample demonstrates how to use MCP Server tools from a protected MCP server with a simple agent|
|[Agent with per-run MCP authentication headers](./Agent_MCP_PerRun_AuthHeaders/)|This sample demonstrates how to attach per-run, refreshable authentication headers to MCP requests using a custom HttpClient handler and an AsyncLocal scope. Uses Microsoft Foundry (`FOUNDRY_PROJECT_ENDPOINT` / `FOUNDRY_MODEL`) rather than the Azure OpenAI variables in the prerequisites above.|
|[Responses Agent with Hosted MCP tool](./ResponseAgent_Hosted_MCP/)|This sample demonstrates how to use the Hosted MCP tool with the Responses Service, where the service invokes any MCP tools directly|
|[Agent with long-running MCP task (transparent polling)](./Agent_MCP_LongRunningTask_Client/)|This sample demonstrates how an agent transparently drives a long-running MCP task (SEP-2663) to completion. The wrapper polls the task internally on both `RunAsync` and `RunStreamingAsync` invocations.|
|[Agent with MCP Tasks extension (transparent polling)](./Agent_MCP_LongRunningTask_Client/)|This sample demonstrates how an agent transparently drives an MCP 2026-07-28 Tasks extension invocation to completion. The wrapper handles inline fallback and polls task-backed calls internally for both `RunAsync` and `RunStreamingAsync`.|
## Running the samples from the console
@@ -35,7 +35,7 @@
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="$(AgentFrameworkVersion)" />
<PackageReference Include="Azure.AI.Projects" Version="2.1.0-beta.4" />
<PackageReference Include="Azure.Identity" Version="1.21.0" />
<PackageReference Include="ModelContextProtocol" Version="1.2.0" />
<PackageReference Include="ModelContextProtocol" Version="2.1.0" />
<PackageReference Include="DotNetEnv" Version="3.1.1" />
</ItemGroup>
@@ -36,7 +36,7 @@
<PackageReference Include="Microsoft.Agents.AI.Mcp" Version="1.15.0-alpha.260722.1" />
<PackageReference Include="Azure.AI.Projects" Version="2.1.0-beta.4" />
<PackageReference Include="Azure.Identity" Version="1.21.0" />
<PackageReference Include="ModelContextProtocol" Version="1.2.0" />
<PackageReference Include="ModelContextProtocol" Version="2.1.0" />
<PackageReference Include="DotNetEnv" Version="3.1.1" />
</ItemGroup>
@@ -25,7 +25,7 @@
<PackageReference Include="Microsoft.Agents.AI.Hosting" Version="$(AgentFrameworkVersion)" />
<PackageReference Include="Azure.AI.OpenAI" Version="2.9.0-beta.1" />
<PackageReference Include="Azure.Identity" Version="1.21.0" />
<PackageReference Include="ModelContextProtocol" Version="1.2.0" />
<PackageReference Include="ModelContextProtocol" Version="2.1.0" />
<PackageReference Include="DotNetEnv" Version="3.1.1" />
</ItemGroup>
@@ -600,9 +600,10 @@ public sealed class FoundryToolboxService : IHostedService, IAsyncDisposable
}
};
// McpClient.CreateAsync runs the MCP initialize handshake and can throw for an unreachable
// proxy (the deferred-toolbox case, retried per request). Keep it inside the try so the
// HttpClient is always disposed on failure rather than leaking a socket on every retry.
// McpClient.CreateAsync performs discovery-first negotiation with down-level fallback and
// can throw for an unreachable proxy (the deferred-toolbox case, retried per request).
// Keep it inside the try so the HttpClient is always disposed on failure rather than
// leaking a socket on every retry.
McpClient? client = null;
IList<McpClientTool> mcpTools;
try
@@ -1,59 +1,134 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Shared.Diagnostics;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
namespace Microsoft.Agents.AI.Mcp;
/// <summary>
/// Extension methods on <see cref="McpClient"/> that expose MCP server tools to a Microsoft
/// Agent Framework agent with optional long-running task (SEP-2663) handling.
/// Agent Framework agent with transparent MCP Tasks extension handling.
/// </summary>
public static class McpClientTaskExtensions
{
private static readonly TimeSpan s_maximumSupportedDelay =
TimeSpan.FromMilliseconds(uint.MaxValue - 1L);
/// <summary>
/// Lists tools advertised by the connected MCP server and returns each as an
/// <see cref="AIFunction"/>. Tools that declare <see cref="ToolTaskSupport.Required"/>
/// are wrapped with task-aware behavior so an agent can transparently drive long-running
/// invocations. All other tools — including those that declare
/// <see cref="ToolTaskSupport.Optional"/> — are returned as-is, preserving inline
/// (synchronous) invocation semantics by default.
/// <see cref="AIFunction"/> that opts into the
/// <see href="https://modelcontextprotocol.io/extensions/tasks/overview">MCP Tasks extension</see>.
/// The returned functions transparently poll task-backed calls to completion and also accept
/// ordinary inline results from servers that do not create a task.
/// </summary>
/// <param name="client">The connected MCP client.</param>
/// <param name="options">
/// Options that control the task lifecycle for task-capable tools.
/// When <see langword="null"/>, defaults described on <see cref="McpTaskOptions"/> apply.
/// Options that control the task lifecycle. When <see langword="null"/>, defaults described
/// on <see cref="McpTaskOptions"/> apply.
/// </param>
/// <param name="cancellationToken">Token used to cancel listing the server's tools.</param>
/// <returns>The tools, ready to pass to <c>AsAIAgent(tools: …)</c>.</returns>
public static async Task<IReadOnlyList<AIFunction>> ListAgentToolsWithTaskSupportAsync(
/// <exception cref="ArgumentOutOfRangeException">
/// <paramref name="options"/> specifies a non-positive lifecycle limit.
/// </exception>
public static async Task<IReadOnlyList<AIFunction>> ListAgentToolsWithTasksAsync(
this McpClient client,
McpTaskOptions? options = null,
CancellationToken cancellationToken = default)
{
_ = Throw.IfNull(client);
McpTaskOptions effectiveOptions = options ?? new McpTaskOptions();
McpTaskOptions effectiveOptions = options ?? new();
if (effectiveOptions.MaxConsecutiveStuckPolls <= 0)
{
throw new ArgumentOutOfRangeException(
nameof(options),
effectiveOptions.MaxConsecutiveStuckPolls,
"MaxConsecutiveStuckPolls must be greater than zero.");
}
if (effectiveOptions.MaxTotalInputRequests <= 0)
{
throw new ArgumentOutOfRangeException(
nameof(options),
effectiveOptions.MaxTotalInputRequests,
"MaxTotalInputRequests must be greater than zero.");
}
if (effectiveOptions.RemoteCancellationTimeout < TimeSpan.FromMilliseconds(1))
{
throw new ArgumentOutOfRangeException(
nameof(options),
effectiveOptions.RemoteCancellationTimeout,
"RemoteCancellationTimeout must be at least one millisecond.");
}
if (effectiveOptions.RemoteCancellationTimeout > s_maximumSupportedDelay)
{
throw new ArgumentOutOfRangeException(
nameof(options),
effectiveOptions.RemoteCancellationTimeout,
$"RemoteCancellationTimeout must not exceed {s_maximumSupportedDelay.TotalMilliseconds} milliseconds.");
}
if (effectiveOptions.MinimumPollingInterval <= TimeSpan.Zero)
{
throw new ArgumentOutOfRangeException(
nameof(options),
effectiveOptions.MinimumPollingInterval,
"MinimumPollingInterval must be greater than zero.");
}
if (effectiveOptions.MaximumPollingInterval < effectiveOptions.MinimumPollingInterval)
{
throw new ArgumentOutOfRangeException(
nameof(options),
effectiveOptions.MaximumPollingInterval,
"MaximumPollingInterval must be greater than or equal to MinimumPollingInterval.");
}
if (effectiveOptions.MaximumPollingInterval > s_maximumSupportedDelay)
{
throw new ArgumentOutOfRangeException(
nameof(options),
effectiveOptions.MaximumPollingInterval,
$"MaximumPollingInterval must not exceed {s_maximumSupportedDelay.TotalMilliseconds} milliseconds.");
}
long minimumPollingIntervalMs =
(long)Math.Ceiling(effectiveOptions.MinimumPollingInterval.TotalMilliseconds);
long maximumPollingIntervalMs =
(long)Math.Floor(effectiveOptions.MaximumPollingInterval.TotalMilliseconds);
if (minimumPollingIntervalMs > maximumPollingIntervalMs)
{
throw new ArgumentOutOfRangeException(
nameof(options),
effectiveOptions.MaximumPollingInterval,
"The polling interval range must contain at least one whole millisecond value.");
}
// Snapshot mutable options before the asynchronous tool-list operation.
effectiveOptions = new McpTaskOptions
{
CancelRemoteTaskOnLocalCancellation = effectiveOptions.CancelRemoteTaskOnLocalCancellation,
MaxConsecutiveStuckPolls = effectiveOptions.MaxConsecutiveStuckPolls,
MaxTotalInputRequests = effectiveOptions.MaxTotalInputRequests,
RemoteCancellationTimeout = effectiveOptions.RemoteCancellationTimeout,
MinimumPollingInterval = TimeSpan.FromMilliseconds(minimumPollingIntervalMs),
MaximumPollingInterval = TimeSpan.FromMilliseconds(maximumPollingIntervalMs),
};
IList<McpClientTool> tools = await client.ListToolsAsync(cancellationToken: cancellationToken).ConfigureAwait(false);
AIFunction[] result = new AIFunction[tools.Count];
for (int i = 0; i < tools.Count; i++)
{
ToolTaskSupport? taskSupport = tools[i].ProtocolTool.Execution?.TaskSupport;
if (taskSupport is ToolTaskSupport.Required)
{
result[i] = new TaskAwareMcpClientAIFunction(client, tools[i], effectiveOptions);
}
else
{
result[i] = tools[i];
}
result[i] = new TaskAwareMcpClientAIFunction(client, tools[i], effectiveOptions);
}
return result;
@@ -5,35 +5,65 @@ using System;
namespace Microsoft.Agents.AI.Mcp;
/// <summary>
/// Configures how an MCP client wrapper drives the
/// <see href="https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks">MCP tasks</see>
/// lifecycle when an underlying server tool returns a <c>CreateTaskResult</c>.
/// Configures how task-aware MCP tools drive the
/// <see href="https://modelcontextprotocol.io/extensions/tasks/overview">MCP Tasks extension</see>
/// lifecycle.
/// </summary>
/// <remarks>
/// <para>
/// All members of this type are subject to change. The MCP task surface is experimental
/// and tracks the in-flight specification.
/// </para>
/// </remarks>
public sealed class McpTaskOptions
{
/// <summary>
/// Gets or sets the time-to-live the wrapper attaches to a newly created server-side task.
/// Gets or sets the timeout for a best-effort <c>tasks/cancel</c> request.
/// </summary>
/// <value>The default is five seconds.</value>
/// <remarks>
/// When <see langword="null"/> the wrapper omits the <c>ttl</c> hint and lets the server
/// pick its own value. The server's chosen TTL is always authoritative.
/// The value must be at least one millisecond and must not exceed the maximum delay
/// supported by the targeted .NET runtimes.
/// </remarks>
public TimeSpan? DefaultTimeToLive { get; set; }
public TimeSpan RemoteCancellationTimeout { get; set; } = TimeSpan.FromSeconds(5);
/// <summary>
/// Gets or sets a value indicating whether the wrapper should send
/// <c>tasks/cancel</c> when the local <see cref="System.Threading.CancellationToken"/>
/// fires during a tool invocation.
/// Gets or sets the minimum server-provided polling interval accepted by the client.
/// </summary>
/// <value>The default is 10 milliseconds.</value>
/// <remarks>The value must be positive and not exceed <see cref="MaximumPollingInterval"/>.</remarks>
public TimeSpan MinimumPollingInterval { get; set; } = TimeSpan.FromMilliseconds(10);
/// <summary>
/// Gets or sets the maximum server-provided polling interval accepted by the client.
/// </summary>
/// <value>The default is the maximum delay supported by the targeted .NET runtimes.</value>
/// <remarks>
/// The value must be at least <see cref="MinimumPollingInterval"/> and must not exceed
/// 4,294,967,294 milliseconds.
/// </remarks>
public TimeSpan MaximumPollingInterval { get; set; } =
TimeSpan.FromMilliseconds(uint.MaxValue - 1L);
/// <summary>
/// Gets or sets a value indicating whether local cancellation should send
/// <c>tasks/cancel</c> for a task-backed invocation.
/// </summary>
/// <remarks>
/// Defaults to <see langword="true"/>: a local cancellation means "the caller is giving up
/// on this tool invocation" and the server-side task has no further consumer.
/// Defaults to <see langword="true"/>. Remote cancellation is best-effort and does not
/// replace the original local cancellation if the server cannot be reached.
/// </remarks>
public bool CancelRemoteTaskOnLocalCancellation { get; set; } = true;
/// <summary>
/// Gets or sets the number of consecutive <c>input_required</c> polls without new input
/// request keys allowed before the task is treated as stuck.
/// </summary>
/// <value>The default is 60.</value>
/// <remarks>The value must be greater than zero.</remarks>
public int MaxConsecutiveStuckPolls { get; set; } = 60;
/// <summary>
/// Gets or sets the maximum number of unique input requests a task may publish.
/// </summary>
/// <value>The default is 100.</value>
/// <remarks>
/// This per-task resource-safety limit bounds retained request keys and user or model
/// interactions. The value must be greater than zero.
/// </remarks>
public int MaxTotalInputRequests { get; set; } = 100;
}
@@ -18,7 +18,7 @@
<PropertyGroup>
<Title>Microsoft Agent Framework MCP</Title>
<Description>Provides Microsoft Agent Framework support for Model Context Protocol (MCP), including long-running task (SEP-2663) integration for MCP clients.</Description>
<Description>Provides Microsoft Agent Framework support for Model Context Protocol (MCP), including MCP Tasks extension (SEP-2663) integration for MCP clients.</Description>
</PropertyGroup>
<!-- Disable package validation baseline until the first release -->
@@ -30,6 +30,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.AI" />
<PackageReference Include="ModelContextProtocol" />
<PackageReference Include="ModelContextProtocol.Extensions.Tasks" />
</ItemGroup>
<ItemGroup>
@@ -1,49 +1,46 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Shared.Diagnostics;
using ModelContextProtocol;
using ModelContextProtocol.Client;
using ModelContextProtocol.Extensions.Tasks;
using ModelContextProtocol.Protocol;
namespace Microsoft.Agents.AI.Mcp;
/// <summary>
/// An <see cref="AIFunction"/> wrapper around an <see cref="McpClientTool"/> that drives the
/// <see href="https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks">MCP long-running task</see>
/// lifecycle (SEP-2663) on behalf of the agent's tool loop.
/// <see href="https://modelcontextprotocol.io/extensions/tasks/overview">MCP Tasks extension</see>
/// lifecycle on behalf of the agent's tool loop.
/// </summary>
/// <remarks>
/// <para>
/// The wrapper invokes the tool with task augmentation via
/// <see cref="McpClient.CallToolAsTaskAsync"/>, polls to completion via
/// <see cref="McpClient.PollTaskUntilCompleteAsync"/>, and fetches the result via
/// <see cref="McpClient.GetTaskResultAsync"/>. The result is returned to the caller as a
/// <see cref="JsonElement"/> containing the serialized <see cref="CallToolResult"/> — the
/// same wire shape produced by <see cref="McpClientTool"/>.<see cref="AIFunction.InvokeAsync(AIFunctionArguments, CancellationToken)"/>
/// so that downstream <see cref="FunctionResultContent"/> serialization is byte-identical to
/// a non-task-augmented MCP tool call. The agent's function-calling loop is unaware that a
/// task was used.
/// </para>
/// <para>
/// This wrapper is intended to be applied only to tools whose
/// <see cref="ToolExecution.TaskSupport"/> is <see cref="ToolTaskSupport.Required"/>
/// (selected by <see cref="McpClientTaskExtensions.ListAgentToolsWithTaskSupportAsync"/>).
/// As a defensive fallback, if the server still rejects the task-augmented call with
/// <see cref="McpErrorCode.MethodNotFound"/> (e.g. because tool-level capabilities changed
/// between <c>tools/list</c> and invocation), the wrapper transparently falls back to a
/// non-augmented call through the inner <see cref="McpClientTool"/>.
/// The wrapper uses the public MCP Tasks extension primitives to retain the created task handle,
/// poll to completion, resolve <c>input_required</c> requests, and cancel remote work when the
/// local invocation is cancelled. Its result projection matches <see cref="McpClientTool"/> so
/// the agent's function-calling loop is unaware whether the server used a task.
/// </para>
/// </remarks>
internal sealed class TaskAwareMcpClientAIFunction : AIFunction
{
private const long DefaultPollIntervalMs = 1000;
private readonly McpClient _client;
private readonly McpClientTool _inner;
private readonly McpTaskOptions _options;
private readonly bool _cancelRemoteTaskOnLocalCancellation;
private readonly int _maxConsecutiveStuckPolls;
private readonly int _maxTotalInputRequests;
private readonly TimeSpan _remoteCancellationTimeout;
private readonly long _minimumPollIntervalMs;
private readonly long _maximumPollIntervalMs;
internal TaskAwareMcpClientAIFunction(McpClient client, McpClientTool inner, McpTaskOptions options)
{
@@ -53,7 +50,12 @@ internal sealed class TaskAwareMcpClientAIFunction : AIFunction
this._client = client;
this._inner = inner;
this._options = options;
this._cancelRemoteTaskOnLocalCancellation = options.CancelRemoteTaskOnLocalCancellation;
this._maxConsecutiveStuckPolls = options.MaxConsecutiveStuckPolls;
this._maxTotalInputRequests = options.MaxTotalInputRequests;
this._remoteCancellationTimeout = options.RemoteCancellationTimeout;
this._minimumPollIntervalMs = (long)Math.Ceiling(options.MinimumPollingInterval.TotalMilliseconds);
this._maximumPollIntervalMs = (long)Math.Floor(options.MaximumPollingInterval.TotalMilliseconds);
}
/// <inheritdoc />
@@ -78,70 +80,239 @@ internal sealed class TaskAwareMcpClientAIFunction : AIFunction
{
_ = Throw.IfNull(arguments);
McpTaskMetadata? metadata = null;
if (this._options.DefaultTimeToLive is TimeSpan ttl)
ResultOrCreatedTask<CallToolResult> invocation = await this._client.CallToolAsTaskAsync(
new CallToolRequestParams
{
Name = this._inner.ProtocolTool.Name,
Arguments = ToArgumentsDictionary(arguments, this.JsonSerializerOptions),
},
cancellationToken: cancellationToken).ConfigureAwait(false);
CallToolResult result;
if (!invocation.IsTask)
{
metadata = new McpTaskMetadata { TimeToLive = ttl };
result = invocation.Result!;
}
else
{
result = await this.PollTaskToCompletionAsync(invocation.TaskCreated!, cancellationToken).ConfigureAwait(false);
}
McpTask task;
try
{
task = await this._client.CallToolAsTaskAsync(
this._inner.Name,
arguments,
taskMetadata: metadata,
progress: null,
options: null,
cancellationToken: cancellationToken).ConfigureAwait(false);
}
catch (McpProtocolException ex) when (ex.ErrorCode == McpErrorCode.MethodNotFound)
{
// Defensive fallback: the server's advertised TaskSupport indicated this tool
// could be invoked as a task, but the server now rejects task augmentation for it
// (e.g. capability changed between tools/list and invocation). Fall back to a
// non-augmented call through the inner McpClientTool.
return await this._inner.InvokeAsync(arguments, cancellationToken).ConfigureAwait(false);
}
return await this.PollAndRetrieveResultAsync(task.TaskId, cancellationToken).ConfigureAwait(false);
return ProjectResult(result, this.JsonSerializerOptions);
}
private async Task<JsonElement> PollAndRetrieveResultAsync(string taskId, CancellationToken cancellationToken)
// This mirrors the MCP SDK 2.1 poller but is maintained here so the wrapper retains
// the task ID needed for remote cancellation. Recompare lifecycle behavior when
// upgrading ModelContextProtocol.Extensions.Tasks.
private async Task<CallToolResult> PollTaskToCompletionAsync(
CreateTaskResult createdTask,
CancellationToken cancellationToken)
{
string taskId = createdTask.TaskId;
long pollIntervalMs = createdTask.PollIntervalMs ??
Math.Clamp(DefaultPollIntervalMs, this._minimumPollIntervalMs, this._maximumPollIntervalMs);
HashSet<string>? observedInputRequestKeys = null;
bool isFirstPoll = true;
int consecutiveStuckPolls = 0;
bool isTerminal = false;
try
{
McpTask terminal = await this._client.PollTaskUntilCompleteAsync(taskId, options: null, cancellationToken).ConfigureAwait(false);
return terminal.Status switch
while (true)
{
McpTaskStatus.Completed => await this._client.GetTaskResultAsync(taskId, options: null, cancellationToken).ConfigureAwait(false),
McpTaskStatus.Cancelled => throw new OperationCanceledException(FormatTerminalStatusMessage(taskId, terminal)),
_ => throw new InvalidOperationException(FormatTerminalStatusMessage(taskId, terminal)),// Failed (or any future non-terminal-but-unhandled status that the poll loop returns).
};
if (!isFirstPoll)
{
await Task.Delay(this.GetValidatedPollDelay(taskId, pollIntervalMs), cancellationToken).ConfigureAwait(false);
}
isFirstPoll = false;
GetTaskResult taskResult = await this._client.GetTaskAsync(taskId, cancellationToken).ConfigureAwait(false);
switch (taskResult)
{
case CompletedTaskResult completed:
isTerminal = true;
return JsonSerializer.Deserialize(
completed.Result,
McpJsonUtilities.DefaultOptions.GetTypeInfo<CallToolResult>())
?? throw new JsonException("Failed to deserialize CallToolResult from completed task.");
case FailedTaskResult failed:
isTerminal = true;
throw new McpException($"Task '{taskId}' failed: {failed.Error}");
case CancelledTaskResult:
isTerminal = true;
throw new OperationCanceledException($"Task '{taskId}' was cancelled by the server.");
case InputRequiredTaskResult inputRequired:
pollIntervalMs = inputRequired.PollIntervalMs ?? pollIntervalMs;
Dictionary<string, InputRequest> newRequests = [];
int observedCount = observedInputRequestKeys?.Count ?? 0;
int remainingInputRequests = this._maxTotalInputRequests - observedCount;
if (inputRequired.InputRequests is { } incomingRequests)
{
foreach (KeyValuePair<string, InputRequest> request in incomingRequests)
{
if (observedInputRequestKeys?.Contains(request.Key) is not true)
{
if (newRequests.Count >= remainingInputRequests)
{
throw new McpException(
$"Task '{taskId}' exceeded the limit of " +
$"{this._maxTotalInputRequests} unique input requests.");
}
newRequests.Add(request.Key, request.Value);
}
}
}
if (newRequests.Count > 0)
{
observedInputRequestKeys ??= new(StringComparer.Ordinal);
foreach (string key in newRequests.Keys)
{
_ = observedInputRequestKeys.Add(key);
}
consecutiveStuckPolls = 0;
IDictionary<string, InputResponse> inputResponses =
await this._client.ResolveInputRequestsAsync(
newRequests,
cancellationToken).ConfigureAwait(false);
_ = await this._client.UpdateTaskAsync(
new UpdateTaskRequestParams
{
TaskId = taskId,
InputResponses = inputResponses,
},
cancellationToken).ConfigureAwait(false);
}
else if (++consecutiveStuckPolls >= this._maxConsecutiveStuckPolls)
{
throw new McpException(
$"Task '{taskId}' has remained in '{McpTaskStatus.InputRequired}' for " +
$"{this._maxConsecutiveStuckPolls} consecutive polls without publishing new input " +
"requests after all previously requested inputs were resolved.");
}
break;
case WorkingTaskResult:
pollIntervalMs = taskResult.PollIntervalMs ?? pollIntervalMs;
consecutiveStuckPolls = 0;
break;
default:
throw new McpException(
$"Unexpected task result type '{taskResult.GetType().Name}' for task '{taskId}'.");
}
}
}
catch (OperationCanceledException) when (this._options.CancelRemoteTaskOnLocalCancellation && cancellationToken.IsCancellationRequested)
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
await this.TryCancelTaskAsync(taskId).ConfigureAwait(false);
if (!isTerminal && this._cancelRemoteTaskOnLocalCancellation)
{
await this.TryCancelTaskAsync(taskId).ConfigureAwait(false);
}
throw;
}
catch
{
if (!isTerminal)
{
await this.TryCancelTaskAsync(taskId).ConfigureAwait(false);
}
throw;
}
}
private static string FormatTerminalStatusMessage(string taskId, McpTask terminal)
=> string.IsNullOrEmpty(terminal.StatusMessage)
? $"MCP task '{taskId}' ended in terminal status '{terminal.Status}'."
: $"MCP task '{taskId}' ended in terminal status '{terminal.Status}': {terminal.StatusMessage}";
private TimeSpan GetValidatedPollDelay(string taskId, long pollIntervalMs)
{
if (pollIntervalMs < this._minimumPollIntervalMs || pollIntervalMs > this._maximumPollIntervalMs)
{
throw new McpException(
$"Task '{taskId}' returned an unusable pollIntervalMs of {pollIntervalMs}. " +
$"The configured range is {this._minimumPollIntervalMs} through " +
$"{this._maximumPollIntervalMs} milliseconds.");
}
return TimeSpan.FromMilliseconds(pollIntervalMs);
}
private static object ProjectResult(CallToolResult result, JsonSerializerOptions serializerOptions)
{
if (result.IsError is not true &&
result.StructuredContent is null &&
!HasApplicationResultMetadata(result.Meta))
{
switch (result.Content.Count)
{
case 1 when result.Content[0].ToAIContent(serializerOptions) is { } aiContent:
return aiContent;
case > 1 when result.Content.Select(c => c.ToAIContent(serializerOptions)).ToArray() is { } aiContents &&
aiContents.All(static c => c is not null):
return aiContents;
}
}
return JsonSerializer.SerializeToElement(
result,
McpJsonUtilities.DefaultOptions.GetTypeInfo<CallToolResult>());
}
private async Task TryCancelTaskAsync(string taskId)
{
try
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
_ = await this._client.CancelTaskAsync(taskId, options: null, cts.Token).ConfigureAwait(false);
using var cts = new CancellationTokenSource(this._remoteCancellationTimeout);
_ = await this._client.CancelTaskAsync(taskId, cts.Token).ConfigureAwait(false);
}
catch
{
// Best-effort cancellation; do not mask the original cancellation reason.
// Remote cancellation is best-effort and must not mask the original failure.
}
}
private static Dictionary<string, JsonElement> ToArgumentsDictionary(
AIFunctionArguments arguments,
JsonSerializerOptions options)
{
var typeInfo = options.GetTypeInfo<object?>();
Dictionary<string, JsonElement> result = new(arguments.Count);
foreach (KeyValuePair<string, object?> argument in arguments)
{
result.Add(
argument.Key,
argument.Value is JsonElement element
? element
: JsonSerializer.SerializeToElement(argument.Value, typeInfo));
}
return result;
}
private static bool HasApplicationResultMetadata(JsonObject? metadata)
{
if (metadata is null)
{
return false;
}
foreach (KeyValuePair<string, JsonNode?> property in metadata)
{
if (!string.Equals(property.Key, MetaKeys.ServerInfo, StringComparison.Ordinal))
{
return true;
}
}
return false;
}
}
@@ -1,15 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
namespace Microsoft.Agents.AI.Mcp.UnitTests;
/// <summary>
/// Minimal empty <see cref="IServiceProvider"/> for in-memory fixtures that don't use DI.
/// </summary>
internal sealed class EmptyServiceProvider : IServiceProvider
{
public static EmptyServiceProvider Instance { get; } = new();
public object? GetService(Type serviceType) => null;
}
@@ -1,13 +1,16 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Pipelines;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using ModelContextProtocol;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using ModelContextProtocol.Client;
using ModelContextProtocol.Extensions.Tasks;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
@@ -20,71 +23,117 @@ namespace Microsoft.Agents.AI.Mcp.UnitTests;
/// </summary>
internal sealed class InMemoryMcpServerFixture : IAsyncDisposable
{
private readonly McpServer _server;
private readonly ServiceProvider _serviceProvider;
private readonly Task _serverLoop;
private readonly CancellationTokenSource _cts;
private readonly RecordingMcpTaskStore? _taskStore;
private readonly TaskRequestObserver _taskRequestObserver;
public McpClient Client { get; }
private InMemoryMcpServerFixture(McpServer server, McpClient client, Task serverLoop, CancellationTokenSource cts)
public int CreatedTaskCount => this._taskStore?.CreatedTaskCount ?? 0;
public int InputRequestCount => this._taskStore?.InputRequestCount ?? 0;
public int PollCount => this._taskStore?.PollCount ?? 0;
public int SuccessfulCancellationTransitionCount =>
this._taskStore?.SuccessfulCancellationTransitionCount ?? 0;
public int CancellationRequestCount => this._taskRequestObserver.CancellationRequestCount;
public Task FirstPollObserved => this._taskStore?.FirstPollObserved
?? throw new InvalidOperationException("Tasks are not enabled for this fixture.");
public Task RemoteCancellationObserved => this._taskStore?.RemoteCancellationObserved
?? throw new InvalidOperationException("Tasks are not enabled for this fixture.");
private InMemoryMcpServerFixture(
ServiceProvider serviceProvider,
McpClient client,
Task serverLoop,
CancellationTokenSource cts,
RecordingMcpTaskStore? taskStore,
TaskRequestObserver taskRequestObserver)
{
this._server = server;
this._serviceProvider = serviceProvider;
this.Client = client;
this._serverLoop = serverLoop;
this._cts = cts;
this._taskStore = taskStore;
this._taskRequestObserver = taskRequestObserver;
}
public static async Task<InMemoryMcpServerFixture> CreateAsync(
McpServerPrimitiveCollection<McpServerTool> tools,
bool enableTasks = true,
McpClientOptions? clientOptions = null,
bool ignoreInputResponses = false,
long initialPollIntervalMs = 10,
long? updatedPollIntervalMs = null,
bool omitPollIntervals = false,
Exception? getTaskException = null,
Exception? resolveInputRequestsException = null,
CancellationToken cancellationToken = default)
{
Pipe clientToServer = new();
Pipe serverToClient = new();
// Stream conventions:
// StreamClientTransport(serverInput, serverOutput, ...): serverInput is what the client
// WRITES to (server reads it); serverOutput is what the client READS from (server writes it).
// StreamServerTransport(input, output, ...): input is what the server READS from; output
// is what the server WRITES to.
Stream clientWriteStream = clientToServer.Writer.AsStream();
Stream clientReadStream = serverToClient.Reader.AsStream();
Stream serverReadStream = clientToServer.Reader.AsStream();
Stream serverWriteStream = serverToClient.Writer.AsStream();
StreamServerTransport serverTransport = new(
serverReadStream,
serverWriteStream,
"test-server",
NullLoggerFactory.Instance);
var services = new ServiceCollection();
services.AddLogging(builder => builder.ClearProviders());
var taskRequestObserver = new TaskRequestObserver();
IMcpServerBuilder builder = services
.AddMcpServer(options =>
{
options.ServerInfo = new Implementation { Name = "test-server", Version = "1.0.0" };
options.Filters.Message.IncomingFilters.Add(next => async (context, ct) =>
{
taskRequestObserver.Observe(context.JsonRpcMessage);
await next(context, ct).ConfigureAwait(false);
});
})
.WithStreamServerTransport(serverReadStream, serverWriteStream)
.WithTools(tools);
McpServerOptions serverOptions = new()
RecordingMcpTaskStore? taskStore = null;
if (enableTasks)
{
ServerInfo = new Implementation { Name = "test-server", Version = "1.0.0" },
TaskStore = new InMemoryMcpTaskStore(),
ToolCollection = tools,
};
McpServer server = McpServer.Create(
serverTransport,
serverOptions,
NullLoggerFactory.Instance,
EmptyServiceProvider.Instance);
taskStore = new RecordingMcpTaskStore(
ignoreInputResponses,
initialPollIntervalMs,
updatedPollIntervalMs,
omitPollIntervals,
getTaskException,
resolveInputRequestsException);
builder.WithTasks(taskStore);
}
ServiceProvider serviceProvider = services.BuildServiceProvider();
McpServer server = serviceProvider.GetRequiredService<McpServer>();
CancellationTokenSource cts = new();
Task serverLoop = Task.Run(() => server.RunAsync(cts.Token), cts.Token);
Task serverLoop = server.RunAsync(cts.Token);
StreamClientTransport clientTransport = new(
clientWriteStream,
clientReadStream,
NullLoggerFactory.Instance);
clientReadStream);
McpClient client = await McpClient.CreateAsync(
clientTransport,
clientOptions: null,
NullLoggerFactory.Instance,
cancellationToken).ConfigureAwait(false);
clientOptions,
cancellationToken: cancellationToken).ConfigureAwait(false);
return new InMemoryMcpServerFixture(server, client, serverLoop, cts);
return new InMemoryMcpServerFixture(
serviceProvider,
client,
serverLoop,
cts,
taskStore,
taskRequestObserver);
}
public async ValueTask DisposeAsync()
@@ -113,15 +162,188 @@ internal sealed class InMemoryMcpServerFixture : IAsyncDisposable
// Best effort.
}
try
{
await this._server.DisposeAsync().ConfigureAwait(false);
}
catch
{
// Best effort.
}
await this._serviceProvider.DisposeAsync().ConfigureAwait(false);
this._cts.Dispose();
}
public Task CancelLatestTaskAsync(CancellationToken cancellationToken = default) =>
this._taskStore?.CancelLatestTaskAsync(cancellationToken)
?? throw new InvalidOperationException("Tasks are not enabled for this fixture.");
public Task FailLatestTaskAsync(JsonElement error, CancellationToken cancellationToken = default) =>
this._taskStore?.FailLatestTaskAsync(error, cancellationToken)
?? throw new InvalidOperationException("Tasks are not enabled for this fixture.");
public Task CompleteLatestTaskAsync(JsonElement result, CancellationToken cancellationToken = default) =>
this._taskStore?.CompleteLatestTaskAsync(result, cancellationToken)
?? throw new InvalidOperationException("Tasks are not enabled for this fixture.");
private sealed class TaskRequestObserver
{
private int _cancellationRequestCount;
public int CancellationRequestCount => this._cancellationRequestCount;
public void Observe(JsonRpcMessage message)
{
if (message is JsonRpcRequest { Method: TasksProtocol.MethodTasksCancel })
{
_ = Interlocked.Increment(ref this._cancellationRequestCount);
}
}
}
private sealed class RecordingMcpTaskStore : IMcpTaskStore
{
private readonly InMemoryMcpTaskStore _inner;
private readonly bool _ignoreInputResponses;
private readonly long? _updatedPollIntervalMs;
private readonly bool _omitPollIntervals;
private readonly Exception? _getTaskException;
private readonly Exception? _resolveInputRequestsException;
private readonly TaskCompletionSource<object?> _firstPollObserved =
new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly TaskCompletionSource<object?> _remoteCancellationObserved =
new(TaskCreationOptions.RunContinuationsAsynchronously);
private int _createdTaskCount;
private int _inputRequestCount;
private int _pollCount;
private int _successfulCancellationTransitionCount;
private string? _latestTaskId;
public RecordingMcpTaskStore(
bool ignoreInputResponses,
long initialPollIntervalMs,
long? updatedPollIntervalMs,
bool omitPollIntervals,
Exception? getTaskException,
Exception? resolveInputRequestsException)
{
this._ignoreInputResponses = ignoreInputResponses;
this._updatedPollIntervalMs = updatedPollIntervalMs;
this._omitPollIntervals = omitPollIntervals;
this._getTaskException = getTaskException;
this._resolveInputRequestsException = resolveInputRequestsException;
this._inner = new InMemoryMcpTaskStore { DefaultPollIntervalMs = initialPollIntervalMs };
}
public int CreatedTaskCount => this._createdTaskCount;
public int InputRequestCount => this._inputRequestCount;
public int PollCount => this._pollCount;
public int SuccessfulCancellationTransitionCount =>
this._successfulCancellationTransitionCount;
public Task FirstPollObserved => this._firstPollObserved.Task;
public Task RemoteCancellationObserved => this._remoteCancellationObserved.Task;
public event Action<InputResponseReceivedEventArgs>? InputResponseReceived
{
add => this._inner.InputResponseReceived += value;
remove => this._inner.InputResponseReceived -= value;
}
public async Task<McpTaskInfo> CreateTaskAsync(CancellationToken cancellationToken = default)
{
McpTaskInfo task = await this._inner.CreateTaskAsync(cancellationToken).ConfigureAwait(false);
if (this._omitPollIntervals)
{
task = task with { PollIntervalMs = null };
}
this._latestTaskId = task.TaskId;
_ = Interlocked.Increment(ref this._createdTaskCount);
return task;
}
public async Task<McpTaskInfo?> GetTaskAsync(string taskId, CancellationToken cancellationToken = default)
{
_ = Interlocked.Increment(ref this._pollCount);
_ = this._firstPollObserved.TrySetResult(null);
if (this._getTaskException is not null)
{
throw this._getTaskException;
}
McpTaskInfo? task = await this._inner.GetTaskAsync(taskId, cancellationToken).ConfigureAwait(false);
if (task is not null && this._omitPollIntervals)
{
task = task with { PollIntervalMs = null };
}
else if (task is not null && this._updatedPollIntervalMs is { } updatedPollIntervalMs)
{
task = task with { PollIntervalMs = updatedPollIntervalMs };
}
return task;
}
public Task SetCompletedAsync(string taskId, JsonElement result, CancellationToken cancellationToken = default) =>
this._inner.SetCompletedAsync(taskId, result, cancellationToken);
public Task SetFailedAsync(string taskId, JsonElement error, CancellationToken cancellationToken = default) =>
this._inner.SetFailedAsync(taskId, error, cancellationToken);
public async Task<bool> SetCancelledAsync(string taskId, CancellationToken cancellationToken = default)
{
bool result = await this._inner.SetCancelledAsync(taskId, cancellationToken).ConfigureAwait(false);
// Count only the first successful terminal transition. The SDK background runner
// may make a later idempotent cancellation attempt after cleanup has already won.
if (result)
{
_ = Interlocked.Increment(ref this._successfulCancellationTransitionCount);
_ = this._remoteCancellationObserved.TrySetResult(null);
}
return result;
}
public Task SetInputRequestsAsync(
string taskId,
IDictionary<string, InputRequest> inputRequests,
CancellationToken cancellationToken = default)
{
_ = Interlocked.Add(ref this._inputRequestCount, inputRequests.Count);
return this._inner.SetInputRequestsAsync(taskId, inputRequests, cancellationToken);
}
public Task ResolveInputRequestsAsync(
string taskId,
IDictionary<string, InputResponse> inputResponses,
CancellationToken cancellationToken = default)
{
if (this._resolveInputRequestsException is not null)
{
throw this._resolveInputRequestsException;
}
return this._ignoreInputResponses
? Task.CompletedTask
: this._inner.ResolveInputRequestsAsync(taskId, inputResponses, cancellationToken);
}
public async Task CancelLatestTaskAsync(CancellationToken cancellationToken)
{
string taskId = this._latestTaskId
?? throw new InvalidOperationException("No task has been created.");
_ = await this.SetCancelledAsync(taskId, cancellationToken).ConfigureAwait(false);
}
public Task FailLatestTaskAsync(JsonElement error, CancellationToken cancellationToken)
{
string taskId = this._latestTaskId
?? throw new InvalidOperationException("No task has been created.");
return this.SetFailedAsync(taskId, error, cancellationToken);
}
public Task CompleteLatestTaskAsync(JsonElement result, CancellationToken cancellationToken)
{
string taskId = this._latestTaskId
?? throw new InvalidOperationException("No task has been created.");
return this.SetCompletedAsync(taskId, result, cancellationToken);
}
}
}
@@ -1,55 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Linq;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Extensions.AI;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
namespace Microsoft.Agents.AI.Mcp.UnitTests;
public class ListAgentToolsWithTaskSupportTests
{
[Fact]
public async Task ListAgentToolsWithTaskSupport_WrapsTaskCapableTools_LeavesOthersAsIsAsync()
{
// Arrange
McpServerPrimitiveCollection<McpServerTool> tools = [
TestTools.Create("opt", ToolTaskSupport.Optional, () => "opt-result"),
TestTools.Create("req", ToolTaskSupport.Required, () => "req-result"),
TestTools.Create("forb", ToolTaskSupport.Forbidden, () => "forb-result"),
TestTools.Create("none", taskSupport: null, () => "none-result"),
];
await using InMemoryMcpServerFixture fixture = await InMemoryMcpServerFixture.CreateAsync(tools);
// Act
var result = await fixture.Client.ListAgentToolsWithTaskSupportAsync();
// Assert
result.Should().HaveCount(4);
AIFunction opt = result.Single(f => f.Name == "opt");
AIFunction req = result.Single(f => f.Name == "req");
AIFunction forb = result.Single(f => f.Name == "forb");
AIFunction none = result.Single(f => f.Name == "none");
req.Should().BeOfType<TaskAwareMcpClientAIFunction>("Required tools must be wrapped");
opt.Should().NotBeOfType<TaskAwareMcpClientAIFunction>("Optional tools must not be wrapped; inline invocation is preserved by default");
forb.Should().NotBeOfType<TaskAwareMcpClientAIFunction>("Forbidden tools must not be wrapped");
none.Should().NotBeOfType<TaskAwareMcpClientAIFunction>("Tools without execution metadata must not be wrapped");
}
[Fact]
public async Task ListAgentToolsWithTaskSupport_ThrowsOnNullClientAsync()
{
// Arrange
ModelContextProtocol.Client.McpClient client = null!;
// Act
Func<Task> act = async () => await client.ListAgentToolsWithTaskSupportAsync();
// Assert
await act.Should().ThrowAsync<ArgumentNullException>();
}
}
@@ -0,0 +1,174 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Linq;
using System.Threading.Tasks;
using FluentAssertions;
using ModelContextProtocol.Server;
namespace Microsoft.Agents.AI.Mcp.UnitTests;
public class ListAgentToolsWithTasksTests
{
[Fact]
public async Task ListAgentToolsWithTasks_WrapsAllToolsAsync()
{
// Arrange
McpServerPrimitiveCollection<McpServerTool> tools = [
TestTools.Create("first", () => "first-result"),
TestTools.Create("second", () => "second-result"),
];
await using InMemoryMcpServerFixture fixture = await InMemoryMcpServerFixture.CreateAsync(tools);
// Act
var result = await fixture.Client.ListAgentToolsWithTasksAsync();
// Assert
result.Should().HaveCount(2);
result.Should().AllBeOfType<TaskAwareMcpClientAIFunction>();
result.Select(tool => tool.Name).Should().Equal("first", "second");
}
[Fact]
public async Task ListAgentToolsWithTasks_ThrowsOnNullClientAsync()
{
// Arrange
ModelContextProtocol.Client.McpClient client = null!;
// Act
Func<Task> act = async () => await client.ListAgentToolsWithTasksAsync();
// Assert
await act.Should().ThrowAsync<ArgumentNullException>();
}
[Fact]
public async Task ListAgentToolsWithTasks_NonPositiveStuckPollLimit_ThrowsAsync()
{
// Arrange
McpServerPrimitiveCollection<McpServerTool> tools = [
TestTools.Create("tool", () => "result"),
];
await using InMemoryMcpServerFixture fixture = await InMemoryMcpServerFixture.CreateAsync(tools);
var options = new McpTaskOptions { MaxConsecutiveStuckPolls = 0 };
// Act
Func<Task> act = async () => await fixture.Client.ListAgentToolsWithTasksAsync(options);
// Assert
await act.Should().ThrowAsync<ArgumentOutOfRangeException>();
}
[Fact]
public async Task ListAgentToolsWithTasks_NonPositiveInputRequestLimit_ThrowsAsync()
{
// Arrange
McpServerPrimitiveCollection<McpServerTool> tools = [
TestTools.Create("tool", () => "result"),
];
await using InMemoryMcpServerFixture fixture = await InMemoryMcpServerFixture.CreateAsync(tools);
var options = new McpTaskOptions { MaxTotalInputRequests = 0 };
// Act
Func<Task> act = async () => await fixture.Client.ListAgentToolsWithTasksAsync(options);
// Assert
await act.Should().ThrowAsync<ArgumentOutOfRangeException>();
}
[Fact]
public async Task ListAgentToolsWithTasks_NonPositiveCancellationTimeout_ThrowsAsync()
{
// Arrange
McpServerPrimitiveCollection<McpServerTool> tools = [
TestTools.Create("tool", () => "result"),
];
await using InMemoryMcpServerFixture fixture = await InMemoryMcpServerFixture.CreateAsync(tools);
var options = new McpTaskOptions { RemoteCancellationTimeout = TimeSpan.Zero };
// Act
Func<Task> act = async () => await fixture.Client.ListAgentToolsWithTasksAsync(options);
// Assert
await act.Should().ThrowAsync<ArgumentOutOfRangeException>();
}
[Fact]
public async Task ListAgentToolsWithTasks_SubMillisecondCancellationTimeout_ThrowsAsync()
{
// Arrange
McpServerPrimitiveCollection<McpServerTool> tools = [
TestTools.Create("tool", () => "result"),
];
await using InMemoryMcpServerFixture fixture = await InMemoryMcpServerFixture.CreateAsync(tools);
var options = new McpTaskOptions { RemoteCancellationTimeout = TimeSpan.FromTicks(1) };
// Act
Func<Task> act = async () => await fixture.Client.ListAgentToolsWithTasksAsync(options);
// Assert
await act.Should().ThrowAsync<ArgumentOutOfRangeException>();
}
[Fact]
public async Task ListAgentToolsWithTasks_InvalidPollingIntervalRange_ThrowsAsync()
{
// Arrange
McpServerPrimitiveCollection<McpServerTool> tools = [
TestTools.Create("tool", () => "result"),
];
await using InMemoryMcpServerFixture fixture = await InMemoryMcpServerFixture.CreateAsync(tools);
var options = new McpTaskOptions
{
MinimumPollingInterval = TimeSpan.FromMilliseconds(20),
MaximumPollingInterval = TimeSpan.FromMilliseconds(10),
};
// Act
Func<Task> act = async () => await fixture.Client.ListAgentToolsWithTasksAsync(options);
// Assert
await act.Should().ThrowAsync<ArgumentOutOfRangeException>();
}
[Fact]
public async Task ListAgentToolsWithTasks_PollingRangeWithoutWholeMillisecond_ThrowsAsync()
{
// Arrange
McpServerPrimitiveCollection<McpServerTool> tools = [
TestTools.Create("tool", () => "result"),
];
await using InMemoryMcpServerFixture fixture = await InMemoryMcpServerFixture.CreateAsync(tools);
var options = new McpTaskOptions
{
MinimumPollingInterval = TimeSpan.FromTicks(1),
MaximumPollingInterval = TimeSpan.FromTicks(TimeSpan.TicksPerMillisecond - 1),
};
// Act
Func<Task> act = async () => await fixture.Client.ListAgentToolsWithTasksAsync(options);
// Assert
await act.Should().ThrowAsync<ArgumentOutOfRangeException>();
}
[Fact]
public async Task ListAgentToolsWithTasks_PollingMaximumAboveRuntimeLimit_ThrowsAsync()
{
// Arrange
McpServerPrimitiveCollection<McpServerTool> tools = [
TestTools.Create("tool", () => "result"),
];
await using InMemoryMcpServerFixture fixture = await InMemoryMcpServerFixture.CreateAsync(tools);
var options = new McpTaskOptions
{
MaximumPollingInterval = TimeSpan.FromMilliseconds(uint.MaxValue),
};
// Act
Func<Task> act = async () => await fixture.Client.ListAgentToolsWithTasksAsync(options);
// Assert
await act.Should().ThrowAsync<ArgumentOutOfRangeException>();
}
}
@@ -1,5 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using FluentAssertions;
namespace Microsoft.Agents.AI.Mcp.UnitTests;
@@ -13,7 +14,11 @@ public class McpTaskOptionsTests
McpTaskOptions options = new();
// Assert
options.DefaultTimeToLive.Should().BeNull();
options.CancelRemoteTaskOnLocalCancellation.Should().BeTrue();
options.MaxConsecutiveStuckPolls.Should().Be(60);
options.MaxTotalInputRequests.Should().Be(100);
options.RemoteCancellationTimeout.Should().Be(TimeSpan.FromSeconds(5));
options.MinimumPollingInterval.Should().Be(TimeSpan.FromMilliseconds(10));
options.MaximumPollingInterval.Should().Be(TimeSpan.FromMilliseconds(uint.MaxValue - 1L));
}
}
@@ -11,6 +11,7 @@
<PackageReference Include="Microsoft.Extensions.Logging" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="ModelContextProtocol" />
<PackageReference Include="ModelContextProtocol.Extensions.Tasks" />
</ItemGroup>
<ItemGroup>
@@ -1,12 +1,14 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Extensions.AI;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
@@ -15,145 +17,785 @@ namespace Microsoft.Agents.AI.Mcp.UnitTests;
public class TaskAwareMcpClientAIFunctionTests
{
[Fact]
public async Task InvokeAsync_RequiredTool_HappyPath_ReturnsResultAsync()
public async Task InvokeAsync_TaskBackedTool_ReturnsResultAsync()
{
// Arrange
McpServerPrimitiveCollection<McpServerTool> tools = [
TestTools.Create("req", ToolTaskSupport.Required, () => "required-result"),
TestTools.Create("task-tool", async () =>
{
await Task.Delay(25);
return "task-result";
}),
];
await using InMemoryMcpServerFixture fixture = await InMemoryMcpServerFixture.CreateAsync(tools);
var result = await fixture.Client.ListAgentToolsWithTaskSupportAsync();
AIFunction req = result.Single(f => f.Name == "req");
req.Should().BeOfType<TaskAwareMcpClientAIFunction>();
AIFunction wrapped = (await fixture.Client.ListAgentToolsWithTasksAsync()).Single();
// Act
object? invokeResult = await req.InvokeAsync(arguments: null, CancellationToken.None);
object? result = await wrapped.InvokeAsync(arguments: null, CancellationToken.None);
// Assert
JsonElement payload = invokeResult.Should().BeOfType<JsonElement>().Subject;
ExtractTextContent(payload).Should().Be("required-result");
result.Should().BeOfType<TextContent>()
.Which.Text.Should().Be("task-result");
fixture.CreatedTaskCount.Should().Be(1);
fixture.PollCount.Should().BeGreaterThan(0);
}
[Fact]
public async Task InvokeAsync_PropagatesDefaultTimeToLiveAsync()
[Theory]
[InlineData(0L)]
[InlineData(-1L)]
[InlineData(4_294_967_295L)]
public async Task InvokeAsync_InvalidInitialPollInterval_CancelsRemoteTaskAsync(long pollIntervalMs)
{
// Arrange — capture the request meta on the server so we can assert TTL flowed through.
TimeSpan? observedTtl = null;
McpServerTool tool = McpServerTool.Create(
(RequestContext<CallToolRequestParams> ctx) =>
{
observedTtl = ctx.Params?.Task?.TimeToLive;
return "ok";
},
new McpServerToolCreateOptions
{
Name = "ttl-tool",
Description = "Echoes the requested TTL.",
Execution = new ToolExecution { TaskSupport = ToolTaskSupport.Required },
});
McpServerPrimitiveCollection<McpServerTool> tools = [tool];
await using InMemoryMcpServerFixture fixture = await InMemoryMcpServerFixture.CreateAsync(tools);
TimeSpan requestedTtl = TimeSpan.FromMinutes(7);
var result = await fixture.Client.ListAgentToolsWithTaskSupportAsync(new McpTaskOptions { DefaultTimeToLive = requestedTtl });
AIFunction wrapped = result.Single();
// Act
_ = await wrapped.InvokeAsync(arguments: null, CancellationToken.None);
// Assert
observedTtl.Should().Be(requestedTtl);
}
[Fact]
public async Task InvokeAsync_RespectsCancellationAsync()
{
// Arrange — a tool that never completes until it's cancelled.
var serverCancelled = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
McpServerTool tool = McpServerTool.Create(
async (CancellationToken ct) =>
{
try
// Arrange
McpServerPrimitiveCollection<McpServerTool> tools = [
TestTools.Create(
"invalid-interval-tool",
async (CancellationToken cancellationToken) =>
{
await Task.Delay(Timeout.Infinite, ct);
}
catch (OperationCanceledException)
{
serverCancelled.TrySetResult(true);
throw;
}
return "should-not-complete";
},
new McpServerToolCreateOptions
{
Name = "blocking",
Description = "Blocks indefinitely until cancelled.",
Execution = new ToolExecution { TaskSupport = ToolTaskSupport.Required },
});
McpServerPrimitiveCollection<McpServerTool> tools = [tool];
await using InMemoryMcpServerFixture fixture = await InMemoryMcpServerFixture.CreateAsync(tools);
var result = await fixture.Client.ListAgentToolsWithTaskSupportAsync();
AIFunction wrapped = result.Single();
using CancellationTokenSource cts = new();
// Act — start the invocation, cancel after a brief delay.
Task<object?> invocation = wrapped.InvokeAsync(arguments: null, cts.Token).AsTask();
await Task.Delay(200);
cts.Cancel();
// Assert — wrapper observes cancellation and signals server-side cancellation.
Func<Task> awaitInvocation = async () => await invocation;
await awaitInvocation.Should().ThrowAsync<OperationCanceledException>();
// Server-side handler should have observed cancellation as a result of the wrapper's
// tasks/cancel call (best-effort wait — give the server-loop a few seconds).
Task observedTask = serverCancelled.Task;
Task completed = await Task.WhenAny(observedTask, Task.Delay(TimeSpan.FromSeconds(5)));
completed.Should().BeSameAs(observedTask, "the wrapper should have issued tasks/cancel");
}
[Fact]
public async Task InvokeAsync_FailedTask_ThrowsInvalidOperationAsync()
{
// Arrange — a tool whose handler throws, which the server surfaces as a Failed task.
McpServerTool tool = McpServerTool.Create(
(Func<string>)(() => throw new InvalidOperationException("simulated tool failure")),
new McpServerToolCreateOptions
{
Name = "boom",
Description = "Throws unconditionally.",
Execution = new ToolExecution { TaskSupport = ToolTaskSupport.Required },
});
McpServerPrimitiveCollection<McpServerTool> tools = [tool];
await using InMemoryMcpServerFixture fixture = await InMemoryMcpServerFixture.CreateAsync(tools);
var result = await fixture.Client.ListAgentToolsWithTaskSupportAsync();
AIFunction wrapped = result.Single();
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
return "unreachable";
}),
];
await using InMemoryMcpServerFixture fixture = await InMemoryMcpServerFixture.CreateAsync(
tools,
initialPollIntervalMs: pollIntervalMs);
AIFunction wrapped = (await fixture.Client.ListAgentToolsWithTasksAsync()).Single();
// Act
Func<Task> act = async () => await wrapped.InvokeAsync(arguments: null, CancellationToken.None);
// Assert — Phase 1 surfaces non-Completed terminal states as InvalidOperationException
// carrying the server's StatusMessage. (See PollAndRetrieveResultAsync.)
await act.Should().ThrowAsync<Exception>().Where(ex =>
ex is InvalidOperationException
|| ex.GetType().FullName == "ModelContextProtocol.McpException");
// Assert
await act.Should().ThrowAsync<ModelContextProtocol.McpException>()
.WithMessage($"*pollIntervalMs of {pollIntervalMs}*");
await fixture.RemoteCancellationObserved.WaitAsync(TimeSpan.FromSeconds(5));
fixture.SuccessfulCancellationTransitionCount.Should().Be(1);
fixture.CancellationRequestCount.Should().Be(1);
}
/// <summary>
/// Extracts the first text-content block from a serialized <c>CallToolResult</c>
/// (the JSON shape returned by the wrapper and by <c>McpClientTool.InvokeAsync</c>).
/// </summary>
private static string ExtractTextContent(JsonElement payload)
[Fact]
public async Task InvokeAsync_InvalidUpdatedPollInterval_CancelsRemoteTaskAsync()
{
payload.ValueKind.Should().Be(JsonValueKind.Object);
JsonElement content = payload.GetProperty("content");
content.ValueKind.Should().Be(JsonValueKind.Array);
JsonElement firstBlock = content.EnumerateArray().First();
return firstBlock.GetProperty("text").GetString()!;
// Arrange
McpServerPrimitiveCollection<McpServerTool> tools = [
TestTools.Create(
"updated-interval-tool",
async (CancellationToken cancellationToken) =>
{
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
return "unreachable";
}),
];
await using InMemoryMcpServerFixture fixture = await InMemoryMcpServerFixture.CreateAsync(
tools,
updatedPollIntervalMs: 0);
AIFunction wrapped = (await fixture.Client.ListAgentToolsWithTasksAsync()).Single();
// Act
Func<Task> act = async () => await wrapped.InvokeAsync(arguments: null, CancellationToken.None);
// Assert
await act.Should().ThrowAsync<ModelContextProtocol.McpException>()
.WithMessage("*pollIntervalMs of 0*");
await fixture.RemoteCancellationObserved.WaitAsync(TimeSpan.FromSeconds(5));
fixture.SuccessfulCancellationTransitionCount.Should().Be(1);
fixture.CancellationRequestCount.Should().Be(1);
}
[Fact]
public async Task InvokeAsync_ConfiguredPollingRange_AcceptsShortServerIntervalAsync()
{
// Arrange
var releaseServer = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
McpServerPrimitiveCollection<McpServerTool> tools = [
TestTools.Create("short-interval-tool", async (CancellationToken cancellationToken) =>
{
await releaseServer.Task.WaitAsync(cancellationToken);
return "completed";
}),
];
await using InMemoryMcpServerFixture fixture = await InMemoryMcpServerFixture.CreateAsync(
tools,
initialPollIntervalMs: 1,
updatedPollIntervalMs: 1);
var options = new McpTaskOptions
{
MinimumPollingInterval = TimeSpan.FromMilliseconds(1),
MaximumPollingInterval = TimeSpan.FromSeconds(1),
};
AIFunction wrapped = (await fixture.Client.ListAgentToolsWithTasksAsync(options)).Single();
Task<object?> invocation = wrapped.InvokeAsync(arguments: null, CancellationToken.None).AsTask();
await fixture.FirstPollObserved.WaitAsync(TimeSpan.FromSeconds(5));
await WaitUntilAsync(() => fixture.PollCount > 1, TimeSpan.FromSeconds(5));
try
{
// Act
_ = releaseServer.TrySetResult(true);
object? result = await invocation;
// Assert
result.Should().BeOfType<TextContent>().Which.Text.Should().Be("completed");
}
finally
{
_ = releaseServer.TrySetResult(true);
}
}
[Fact]
public async Task InvokeAsync_MissingPollInterval_ConstrainsFallbackToConfiguredRangeAsync()
{
// Arrange
var releaseServer = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
McpServerPrimitiveCollection<McpServerTool> tools = [
TestTools.Create("missing-interval-tool", async (CancellationToken cancellationToken) =>
{
await releaseServer.Task.WaitAsync(cancellationToken);
return "completed";
}),
];
await using InMemoryMcpServerFixture fixture = await InMemoryMcpServerFixture.CreateAsync(
tools,
omitPollIntervals: true);
var options = new McpTaskOptions
{
MinimumPollingInterval = TimeSpan.FromMilliseconds(10),
MaximumPollingInterval = TimeSpan.FromMilliseconds(100),
};
AIFunction wrapped = (await fixture.Client.ListAgentToolsWithTasksAsync(options)).Single();
Task<object?> invocation = wrapped.InvokeAsync(arguments: null, CancellationToken.None).AsTask();
await fixture.FirstPollObserved.WaitAsync(TimeSpan.FromSeconds(5));
await WaitUntilAsync(() => fixture.PollCount > 1, TimeSpan.FromSeconds(5));
try
{
// Act
_ = releaseServer.TrySetResult(true);
object? result = await invocation;
// Assert
result.Should().BeOfType<TextContent>().Which.Text.Should().Be("completed");
}
finally
{
_ = releaseServer.TrySetResult(true);
}
}
[Fact]
public async Task InvokeAsync_ServerWithoutTasks_ReturnsInlineResultAsync()
{
// Arrange
McpServerPrimitiveCollection<McpServerTool> tools = [
TestTools.Create("inline-tool", () => "inline-result"),
];
await using InMemoryMcpServerFixture fixture = await InMemoryMcpServerFixture.CreateAsync(tools, enableTasks: false);
AIFunction wrapped = (await fixture.Client.ListAgentToolsWithTasksAsync()).Single();
// Act
object? result = await wrapped.InvokeAsync(arguments: null, CancellationToken.None);
// Assert
result.Should().BeOfType<TextContent>()
.Which.Text.Should().Be("inline-result");
fixture.CreatedTaskCount.Should().Be(0);
}
[Fact]
public async Task InvokeAsync_InputRequired_DispatchesClientHandlerAsync()
{
// Arrange
McpServerPrimitiveCollection<McpServerTool> tools = [
McpServerTool.Create(
async (McpServer server, CancellationToken cancellationToken) =>
{
ElicitResult elicitation = await server.ElicitAsync(
new ElicitRequestParams
{
Message = "Confirm the operation.",
RequestedSchema = new(),
},
cancellationToken);
return $"{elicitation.Action}:{elicitation.Content!["confirmed"].GetString()}";
},
new McpServerToolCreateOptions
{
Name = "input-tool",
Description = "Requests confirmation before completing.",
}),
];
var clientOptions = new McpClientOptions();
clientOptions.Handlers.ElicitationHandler = (_, _) =>
new ValueTask<ElicitResult>(
new ElicitResult
{
Action = "accept",
Content = new Dictionary<string, JsonElement>
{
["confirmed"] = JsonSerializer.SerializeToElement("yes"),
},
});
await using InMemoryMcpServerFixture fixture = await InMemoryMcpServerFixture.CreateAsync(
tools,
clientOptions: clientOptions);
AIFunction wrapped = (await fixture.Client.ListAgentToolsWithTasksAsync()).Single();
// Act
object? result = await wrapped.InvokeAsync(arguments: null, CancellationToken.None);
// Assert
result.Should().BeOfType<TextContent>()
.Which.Text.Should().Be("accept:yes");
fixture.CreatedTaskCount.Should().Be(1);
fixture.InputRequestCount.Should().Be(1);
}
[Fact]
public async Task InvokeAsync_ForwardsNullPrimitiveAndComplexArgumentsAsync()
{
// Arrange
IDictionary<string, JsonElement>? observedArguments = null;
McpServerPrimitiveCollection<McpServerTool> tools = [
McpServerTool.Create(
(RequestContext<CallToolRequestParams> context) =>
{
observedArguments = context.Params?.Arguments;
return "ok";
},
new McpServerToolCreateOptions
{
Name = "arguments-tool",
Description = "Captures arguments.",
}),
];
await using InMemoryMcpServerFixture fixture = await InMemoryMcpServerFixture.CreateAsync(tools);
AIFunction wrapped = (await fixture.Client.ListAgentToolsWithTasksAsync()).Single();
var arguments = new AIFunctionArguments
{
["optional"] = null,
["count"] = 3,
["payload"] = new Dictionary<string, object?> { ["label"] = "nested" },
};
// Act
_ = await wrapped.InvokeAsync(arguments, CancellationToken.None);
// Assert
observedArguments.Should().NotBeNull();
observedArguments!["optional"].ValueKind.Should().Be(JsonValueKind.Null);
observedArguments["count"].GetInt32().Should().Be(3);
observedArguments["payload"].GetProperty("label").GetString().Should().Be("nested");
}
[Fact]
public async Task InvokeAsync_SimpleResult_MatchesMcpClientToolProjectionAsync()
{
// Arrange
McpServerPrimitiveCollection<McpServerTool> tools = [
TestTools.Create("projection-tool", () => "projected-result"),
];
await using InMemoryMcpServerFixture fixture = await InMemoryMcpServerFixture.CreateAsync(tools);
McpClientTool inner = (await fixture.Client.ListToolsAsync()).Single();
AIFunction wrapped = (await fixture.Client.ListAgentToolsWithTasksAsync()).Single();
// Act
object? innerResult = await inner.InvokeAsync(arguments: null, CancellationToken.None);
object? wrappedResult = await wrapped.InvokeAsync(arguments: null, CancellationToken.None);
// Assert
wrappedResult.Should().BeEquivalentTo(innerResult);
}
[Fact]
public async Task InvokeAsync_ToolError_PreservesCallToolResultEnvelopeAsync()
{
// Arrange
McpServerPrimitiveCollection<McpServerTool> tools = [
TestTools.Create(
"error-tool",
() => new CallToolResult
{
IsError = true,
Content = [new TextContentBlock { Text = "tool failed" }],
}),
];
await using InMemoryMcpServerFixture fixture = await InMemoryMcpServerFixture.CreateAsync(tools);
AIFunction wrapped = (await fixture.Client.ListAgentToolsWithTasksAsync()).Single();
// Act
object? result = await wrapped.InvokeAsync(arguments: null, CancellationToken.None);
// Assert
JsonElement payload = result.Should().BeOfType<JsonElement>().Subject;
payload.GetProperty("isError").GetBoolean().Should().BeTrue();
payload.GetProperty("content")[0].GetProperty("text").GetString().Should().Be("tool failed");
}
[Fact]
public async Task InvokeAsync_FailedTask_ThrowsMcpExceptionAsync()
{
// Arrange
var releaseServer = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
McpServerPrimitiveCollection<McpServerTool> tools = [
TestTools.Create(
"failed-tool",
async () =>
{
await releaseServer.Task;
return "released";
}),
];
await using InMemoryMcpServerFixture fixture = await InMemoryMcpServerFixture.CreateAsync(tools);
AIFunction wrapped = (await fixture.Client.ListAgentToolsWithTasksAsync()).Single();
Task<object?> invocation = wrapped.InvokeAsync(arguments: null, CancellationToken.None).AsTask();
try
{
await fixture.FirstPollObserved.WaitAsync(TimeSpan.FromSeconds(5));
await fixture.FailLatestTaskAsync(
JsonSerializer.SerializeToElement(new { code = -32603, message = "simulated failure" }));
// Act
Func<Task> act = async () => await invocation;
// Assert
await act.Should().ThrowAsync<ModelContextProtocol.McpException>()
.WithMessage("*simulated failure*");
fixture.SuccessfulCancellationTransitionCount.Should().Be(0);
fixture.CancellationRequestCount.Should().Be(0);
}
finally
{
_ = releaseServer.TrySetResult(true);
}
}
[Fact]
public async Task InvokeAsync_ServerCancelledTask_ThrowsOperationCanceledAsync()
{
// Arrange
var releaseServer = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
McpServerPrimitiveCollection<McpServerTool> tools = [
TestTools.Create(
"server-cancelled-tool",
async () =>
{
await releaseServer.Task;
return "released";
}),
];
await using InMemoryMcpServerFixture fixture = await InMemoryMcpServerFixture.CreateAsync(tools);
AIFunction wrapped = (await fixture.Client.ListAgentToolsWithTasksAsync()).Single();
Task<object?> invocation = wrapped.InvokeAsync(arguments: null, CancellationToken.None).AsTask();
try
{
await fixture.FirstPollObserved.WaitAsync(TimeSpan.FromSeconds(5));
await fixture.CancelLatestTaskAsync();
// Act
Func<Task> act = async () => await invocation;
// Assert
await act.Should().ThrowAsync<OperationCanceledException>()
.WithMessage("*cancelled by the server*");
fixture.SuccessfulCancellationTransitionCount.Should().Be(1);
fixture.CancellationRequestCount.Should().Be(0);
}
finally
{
_ = releaseServer.TrySetResult(true);
}
}
[Fact]
public async Task InvokeAsync_InputHandlerFailure_CancelsRemoteTaskAsync()
{
// Arrange
McpServerPrimitiveCollection<McpServerTool> tools = [
McpServerTool.Create(
async (McpServer server, CancellationToken cancellationToken) =>
{
_ = await server.ElicitAsync(
new ElicitRequestParams
{
Message = "Confirm the operation.",
RequestedSchema = new(),
},
cancellationToken);
return "unreachable";
},
new McpServerToolCreateOptions
{
Name = "failing-input-tool",
Description = "Requests input that the client cannot provide.",
}),
];
var clientOptions = new McpClientOptions();
clientOptions.Handlers.ElicitationHandler = (_, _) =>
throw new InvalidOperationException("input handler failed");
await using InMemoryMcpServerFixture fixture = await InMemoryMcpServerFixture.CreateAsync(
tools,
clientOptions: clientOptions);
AIFunction wrapped = (await fixture.Client.ListAgentToolsWithTasksAsync()).Single();
// Act
Func<Task> act = async () => await wrapped.InvokeAsync(arguments: null, CancellationToken.None);
// Assert
await act.Should().ThrowAsync<InvalidOperationException>()
.WithMessage("input handler failed");
await fixture.RemoteCancellationObserved.WaitAsync(TimeSpan.FromSeconds(5));
fixture.SuccessfulCancellationTransitionCount.Should().Be(1);
fixture.CancellationRequestCount.Should().Be(1);
}
[Fact]
public async Task InvokeAsync_GetTaskFailure_CancelsRemoteTaskAndPreservesProtocolExceptionAsync()
{
// Arrange
McpServerPrimitiveCollection<McpServerTool> tools = [
TestTools.Create(
"get-failure-tool",
async (CancellationToken cancellationToken) =>
{
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
return "unreachable";
}),
];
await using InMemoryMcpServerFixture fixture = await InMemoryMcpServerFixture.CreateAsync(
tools,
getTaskException: new InvalidOperationException("get task failed"));
AIFunction wrapped = (await fixture.Client.ListAgentToolsWithTasksAsync()).Single();
// Act
Func<Task> act = async () => await wrapped.InvokeAsync(arguments: null, CancellationToken.None);
// Assert
await act.Should().ThrowAsync<ModelContextProtocol.McpProtocolException>()
.WithMessage("Request failed (remote): An error occurred.");
await fixture.RemoteCancellationObserved.WaitAsync(TimeSpan.FromSeconds(5));
fixture.SuccessfulCancellationTransitionCount.Should().Be(1);
fixture.CancellationRequestCount.Should().Be(1);
}
[Fact]
public async Task InvokeAsync_UpdateTaskFailure_CancelsRemoteTaskAndPreservesProtocolExceptionAsync()
{
// Arrange
McpServerPrimitiveCollection<McpServerTool> tools = [
McpServerTool.Create(
async (McpServer server, CancellationToken cancellationToken) =>
{
_ = await server.ElicitAsync(
new ElicitRequestParams
{
Message = "Confirm the operation.",
RequestedSchema = new(),
},
cancellationToken);
return "unreachable";
},
new McpServerToolCreateOptions
{
Name = "update-failure-tool",
Description = "Fails while accepting an input response.",
}),
];
var clientOptions = new McpClientOptions();
clientOptions.Handlers.ElicitationHandler = (_, _) =>
new ValueTask<ElicitResult>(new ElicitResult { Action = "accept" });
await using InMemoryMcpServerFixture fixture = await InMemoryMcpServerFixture.CreateAsync(
tools,
clientOptions: clientOptions,
resolveInputRequestsException: new InvalidOperationException("update task failed"));
AIFunction wrapped = (await fixture.Client.ListAgentToolsWithTasksAsync()).Single();
// Act
Func<Task> act = async () => await wrapped.InvokeAsync(arguments: null, CancellationToken.None);
// Assert
await act.Should().ThrowAsync<ModelContextProtocol.McpProtocolException>()
.WithMessage("Request failed (remote): An error occurred.");
await fixture.RemoteCancellationObserved.WaitAsync(TimeSpan.FromSeconds(5));
fixture.SuccessfulCancellationTransitionCount.Should().Be(1);
fixture.CancellationRequestCount.Should().Be(1);
}
[Fact]
public async Task InvokeAsync_MalformedCompletedResult_DoesNotCancelTerminalTaskAsync()
{
// Arrange
var releaseServer = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
McpServerPrimitiveCollection<McpServerTool> tools = [
TestTools.Create(
"malformed-result-tool",
async () =>
{
await releaseServer.Task;
return "released";
}),
];
await using InMemoryMcpServerFixture fixture = await InMemoryMcpServerFixture.CreateAsync(tools);
AIFunction wrapped = (await fixture.Client.ListAgentToolsWithTasksAsync()).Single();
Task<object?> invocation = wrapped.InvokeAsync(arguments: null, CancellationToken.None).AsTask();
try
{
await fixture.FirstPollObserved.WaitAsync(TimeSpan.FromSeconds(5));
await fixture.CompleteLatestTaskAsync(JsonSerializer.SerializeToElement("malformed"));
// Act
Func<Task> act = async () => await invocation;
// Assert
await act.Should().ThrowAsync<JsonException>();
fixture.SuccessfulCancellationTransitionCount.Should().Be(0);
fixture.CancellationRequestCount.Should().Be(0);
}
finally
{
_ = releaseServer.TrySetResult(true);
}
}
[Fact]
public async Task InvokeAsync_StuckInputRequired_CancelsRemoteTaskAsync()
{
// Arrange
McpServerPrimitiveCollection<McpServerTool> tools = [
McpServerTool.Create(
async (McpServer server, CancellationToken cancellationToken) =>
{
_ = await server.ElicitAsync(
new ElicitRequestParams
{
Message = "Confirm the operation.",
RequestedSchema = new(),
},
cancellationToken);
return "unreachable";
},
new McpServerToolCreateOptions
{
Name = "stuck-input-tool",
Description = "Remains input-required after receiving a response.",
}),
];
var clientOptions = new McpClientOptions();
clientOptions.Handlers.ElicitationHandler = (_, _) =>
new ValueTask<ElicitResult>(new ElicitResult { Action = "accept" });
await using InMemoryMcpServerFixture fixture = await InMemoryMcpServerFixture.CreateAsync(
tools,
clientOptions: clientOptions,
ignoreInputResponses: true);
var options = new McpTaskOptions { MaxConsecutiveStuckPolls = 2 };
AIFunction wrapped = (await fixture.Client.ListAgentToolsWithTasksAsync(options)).Single();
// Act
Func<Task> act = async () => await wrapped.InvokeAsync(arguments: null, CancellationToken.None);
// Assert
await act.Should().ThrowAsync<ModelContextProtocol.McpException>()
.WithMessage("*2 consecutive polls*");
await fixture.RemoteCancellationObserved.WaitAsync(TimeSpan.FromSeconds(5));
fixture.SuccessfulCancellationTransitionCount.Should().Be(1);
fixture.CancellationRequestCount.Should().Be(1);
}
[Fact]
public async Task InvokeAsync_InputRequestsAtLimit_CompletesAsync()
{
// Arrange
int handledInputRequests = 0;
McpServerPrimitiveCollection<McpServerTool> tools = [
McpServerTool.Create(
async (McpServer server, CancellationToken cancellationToken) =>
{
for (int i = 0; i < 2; i++)
{
_ = await server.ElicitAsync(
new ElicitRequestParams
{
Message = $"Confirm operation {i}.",
RequestedSchema = new(),
},
cancellationToken);
}
return "completed";
},
new McpServerToolCreateOptions
{
Name = "bounded-input-tool",
Description = "Requests input up to the configured limit.",
}),
];
var clientOptions = new McpClientOptions();
clientOptions.Handlers.ElicitationHandler = (_, _) =>
{
_ = Interlocked.Increment(ref handledInputRequests);
return new ValueTask<ElicitResult>(new ElicitResult { Action = "accept" });
};
await using InMemoryMcpServerFixture fixture = await InMemoryMcpServerFixture.CreateAsync(
tools,
clientOptions: clientOptions);
var options = new McpTaskOptions { MaxTotalInputRequests = 2 };
AIFunction wrapped = (await fixture.Client.ListAgentToolsWithTasksAsync(options)).Single();
// Act
object? result = await wrapped.InvokeAsync(arguments: null, CancellationToken.None);
// Assert
result.Should().BeOfType<TextContent>().Which.Text.Should().Be("completed");
handledInputRequests.Should().Be(2);
fixture.SuccessfulCancellationTransitionCount.Should().Be(0);
fixture.CancellationRequestCount.Should().Be(0);
}
[Fact]
public async Task InvokeAsync_InputRequestLimitExceeded_CancelsBeforeDispatchAsync()
{
// Arrange
int handledInputRequests = 0;
McpServerPrimitiveCollection<McpServerTool> tools = [
McpServerTool.Create(
async (McpServer server, CancellationToken cancellationToken) =>
{
for (int i = 0; i < 3; i++)
{
_ = await server.ElicitAsync(
new ElicitRequestParams
{
Message = $"Confirm operation {i}.",
RequestedSchema = new(),
},
cancellationToken);
}
return "unreachable";
},
new McpServerToolCreateOptions
{
Name = "unbounded-input-tool",
Description = "Exceeds the configured input request limit.",
}),
];
var clientOptions = new McpClientOptions();
clientOptions.Handlers.ElicitationHandler = (_, _) =>
{
_ = Interlocked.Increment(ref handledInputRequests);
return new ValueTask<ElicitResult>(new ElicitResult { Action = "accept" });
};
await using InMemoryMcpServerFixture fixture = await InMemoryMcpServerFixture.CreateAsync(
tools,
clientOptions: clientOptions);
var options = new McpTaskOptions { MaxTotalInputRequests = 2 };
AIFunction wrapped = (await fixture.Client.ListAgentToolsWithTasksAsync(options)).Single();
// Act
Func<Task> act = async () => await wrapped.InvokeAsync(arguments: null, CancellationToken.None);
// Assert
await act.Should().ThrowAsync<ModelContextProtocol.McpException>()
.WithMessage("*limit of 2 unique input requests*");
handledInputRequests.Should().Be(2);
await fixture.RemoteCancellationObserved.WaitAsync(TimeSpan.FromSeconds(5));
fixture.SuccessfulCancellationTransitionCount.Should().Be(1);
fixture.CancellationRequestCount.Should().Be(1);
}
[Fact]
public async Task InvokeAsync_LocalCancellation_CancelsRemoteTaskAsync()
{
// Arrange
var serverCancelled = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
McpServerPrimitiveCollection<McpServerTool> tools = [
TestTools.Create(
"blocking-tool",
async (CancellationToken cancellationToken) =>
{
try
{
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
}
catch (OperationCanceledException)
{
_ = serverCancelled.TrySetResult(true);
throw;
}
return "unreachable";
}),
];
await using InMemoryMcpServerFixture fixture = await InMemoryMcpServerFixture.CreateAsync(tools);
AIFunction wrapped = (await fixture.Client.ListAgentToolsWithTasksAsync()).Single();
using var cts = new CancellationTokenSource();
Task<object?> invocation = wrapped.InvokeAsync(arguments: null, cts.Token).AsTask();
await fixture.FirstPollObserved.WaitAsync(TimeSpan.FromSeconds(5));
// Act
cts.Cancel();
Func<Task> act = async () => await invocation;
// Assert
await act.Should().ThrowAsync<OperationCanceledException>();
await fixture.RemoteCancellationObserved.WaitAsync(TimeSpan.FromSeconds(5));
await serverCancelled.Task.WaitAsync(TimeSpan.FromSeconds(5));
fixture.CreatedTaskCount.Should().Be(1);
fixture.PollCount.Should().BeGreaterThan(0);
fixture.SuccessfulCancellationTransitionCount.Should().Be(1);
fixture.CancellationRequestCount.Should().Be(1);
}
[Fact]
public async Task InvokeAsync_LocalCancellation_DoesNotCancelRemoteTaskWhenDisabledAsync()
{
// Arrange
var releaseServer = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
McpServerPrimitiveCollection<McpServerTool> tools = [
TestTools.Create(
"detached-tool",
async () =>
{
await releaseServer.Task;
return "released";
}),
];
await using InMemoryMcpServerFixture fixture = await InMemoryMcpServerFixture.CreateAsync(tools);
var options = new McpTaskOptions { CancelRemoteTaskOnLocalCancellation = false };
AIFunction wrapped = (await fixture.Client.ListAgentToolsWithTasksAsync(options)).Single();
using var cts = new CancellationTokenSource();
Task<object?> invocation = wrapped.InvokeAsync(arguments: null, cts.Token).AsTask();
try
{
await fixture.FirstPollObserved.WaitAsync(TimeSpan.FromSeconds(5));
// Act
cts.Cancel();
Func<Task> act = async () => await invocation;
// Assert
await act.Should().ThrowAsync<OperationCanceledException>();
fixture.SuccessfulCancellationTransitionCount.Should().Be(0);
fixture.CancellationRequestCount.Should().Be(0);
}
finally
{
_ = releaseServer.TrySetResult(true);
}
}
private static async Task WaitUntilAsync(Func<bool> predicate, TimeSpan timeout)
{
using var cts = new CancellationTokenSource(timeout);
while (!predicate())
{
await Task.Delay(TimeSpan.FromMilliseconds(10), cts.Token);
}
}
}
@@ -1,30 +1,21 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
namespace Microsoft.Agents.AI.Mcp.UnitTests;
/// <summary>
/// Helpers to create <see cref="McpServerTool"/> instances with a specific
/// <see cref="ToolTaskSupport"/> level for in-memory fixtures.
/// Helpers to create <see cref="McpServerTool"/> instances for in-memory fixtures.
/// </summary>
internal static class TestTools
{
public static McpServerTool Create(string name, ToolTaskSupport? taskSupport, Delegate handler)
{
McpServerToolCreateOptions options = new()
{
Name = name,
Description = $"Test tool {name}.",
};
if (taskSupport is ToolTaskSupport ts)
{
options.Execution = new ToolExecution { TaskSupport = ts };
}
return McpServerTool.Create(handler, options);
}
public static McpServerTool Create(string name, Delegate handler) =>
McpServerTool.Create(
handler,
new McpServerToolCreateOptions
{
Name = name,
Description = $"Test tool {name}.",
});
}
@@ -846,6 +846,7 @@ public sealed class DefaultMcpToolHandlerTests
uriContent.AdditionalProperties!["filename"].Should().Be("resource.bin");
}
#pragma warning disable MCP9005 // Verify compatibility mapping for deprecated sampling content blocks.
[Fact]
public void ConvertContentBlock_ToolUseContentBlock_ShouldReturnFunctionCallContent()
{
@@ -911,6 +912,7 @@ public sealed class DefaultMcpToolHandlerTests
functionResult.Exception.Should().NotBeNull();
functionResult.RawRepresentation.Should().BeSameAs(block);
}
#pragma warning restore MCP9005
[Fact]
public void ConvertContentBlock_BlockWithMeta_ShouldPropagateToAdditionalProperties()