.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:
@@ -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) ===",
|
||||
],
|
||||
|
||||
+1
@@ -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>
|
||||
|
||||
+14
-20
@@ -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,
|
||||
|
||||
+12
-8
@@ -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
|
||||
|
||||
|
||||
+1
-1
@@ -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>
|
||||
|
||||
|
||||
+1
-1
@@ -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>
|
||||
|
||||
|
||||
+1
-1
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -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>
|
||||
|
||||
+762
-120
@@ -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}.",
|
||||
});
|
||||
}
|
||||
|
||||
+2
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user