diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index 8b0dd5d35..64825701f 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -45,10 +45,10 @@ - + - + @@ -120,7 +120,8 @@ - + + diff --git a/dotnet/eng/verify-samples/AgentsSamples.cs b/dotnet/eng/verify-samples/AgentsSamples.cs index f629bdf60..f8cc340e9 100644 --- a/dotnet/eng/verify-samples/AgentsSamples.cs +++ b/dotnet/eng/verify-samples/AgentsSamples.cs @@ -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) ===", ], diff --git a/dotnet/samples/02-agents/ModelContextProtocol/Agent_MCP_LongRunningTask_Client/Agent_MCP_LongRunningTask_Client.csproj b/dotnet/samples/02-agents/ModelContextProtocol/Agent_MCP_LongRunningTask_Client/Agent_MCP_LongRunningTask_Client.csproj index b69820c46..fb36ab7e1 100644 --- a/dotnet/samples/02-agents/ModelContextProtocol/Agent_MCP_LongRunningTask_Client/Agent_MCP_LongRunningTask_Client.csproj +++ b/dotnet/samples/02-agents/ModelContextProtocol/Agent_MCP_LongRunningTask_Client/Agent_MCP_LongRunningTask_Client.csproj @@ -15,6 +15,7 @@ + diff --git a/dotnet/samples/02-agents/ModelContextProtocol/Agent_MCP_LongRunningTask_Client/Program.cs b/dotnet/samples/02-agents/ModelContextProtocol/Agent_MCP_LongRunningTask_Client/Program.cs index 83b4393c7..99ce16242 100644 --- a/dotnet/samples/02-agents/ModelContextProtocol/Agent_MCP_LongRunningTask_Client/Program.cs +++ b/dotnet/samples/02-agents/ModelContextProtocol/Agent_MCP_LongRunningTask_Client/Program.cs @@ -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(); + .WithTools() + .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 AnalyzeDatasetAsync( [Description("The dataset identifier, e.g. 'sales-2025-q1'.")] string datasetName, diff --git a/dotnet/samples/02-agents/ModelContextProtocol/Agent_MCP_LongRunningTask_Client/README.md b/dotnet/samples/02-agents/ModelContextProtocol/Agent_MCP_LongRunningTask_Client/README.md index 76d884952..51a97ea70 100644 --- a/dotnet/samples/02-agents/ModelContextProtocol/Agent_MCP_LongRunningTask_Client/README.md +++ b/dotnet/samples/02-agents/ModelContextProtocol/Agent_MCP_LongRunningTask_Client/README.md @@ -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: diff --git a/dotnet/samples/02-agents/ModelContextProtocol/Agent_MCP_Server_Auth/Program.cs b/dotnet/samples/02-agents/ModelContextProtocol/Agent_MCP_Server_Auth/Program.cs index aaa4b0d11..362f59137 100644 --- a/dotnet/samples/02-agents/ModelContextProtocol/Agent_MCP_Server_Auth/Program.cs +++ b/dotnet/samples/02-agents/ModelContextProtocol/Agent_MCP_Server_Auth/Program.cs @@ -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 HandleAuthorizationUrlAsync(Uri authorizationUrl, Uri redirectUri, CancellationToken cancellationToken) +static async Task 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 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 = "

Authentication complete

You can close this window now.

"; @@ -102,14 +107,19 @@ static async Task 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) { diff --git a/dotnet/samples/02-agents/ModelContextProtocol/README.md b/dotnet/samples/02-agents/ModelContextProtocol/README.md index d9a1b1a4d..37fbdc60e 100644 --- a/dotnet/samples/02-agents/ModelContextProtocol/README.md +++ b/dotnet/samples/02-agents/ModelContextProtocol/README.md @@ -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 diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/HostedMcpTools.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/HostedMcpTools.csproj index 0621a4ac3..a7b1ac076 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/HostedMcpTools.csproj +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/HostedMcpTools.csproj @@ -35,7 +35,7 @@ - +
diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/HostedToolboxMcpSkills.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/HostedToolboxMcpSkills.csproj index 09ae2d837..f9229d5bc 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/HostedToolboxMcpSkills.csproj +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/HostedToolboxMcpSkills.csproj @@ -36,7 +36,7 @@ - + diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/HostedWorkflowHandoff.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/HostedWorkflowHandoff.csproj index 4ca7577a2..aeb5cd12f 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/HostedWorkflowHandoff.csproj +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/HostedWorkflowHandoff.csproj @@ -25,7 +25,7 @@ - + diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryToolboxService.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryToolboxService.cs index 846360e5e..a64ba352d 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryToolboxService.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryToolboxService.cs @@ -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 mcpTools; try diff --git a/dotnet/src/Microsoft.Agents.AI.Mcp/McpClientTaskExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Mcp/McpClientTaskExtensions.cs index 77bbf6053..9b681089f 100644 --- a/dotnet/src/Microsoft.Agents.AI.Mcp/McpClientTaskExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Mcp/McpClientTaskExtensions.cs @@ -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; /// /// Extension methods on 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. /// public static class McpClientTaskExtensions { + private static readonly TimeSpan s_maximumSupportedDelay = + TimeSpan.FromMilliseconds(uint.MaxValue - 1L); + /// /// Lists tools advertised by the connected MCP server and returns each as an - /// . Tools that declare - /// are wrapped with task-aware behavior so an agent can transparently drive long-running - /// invocations. All other tools — including those that declare - /// — are returned as-is, preserving inline - /// (synchronous) invocation semantics by default. + /// that opts into the + /// MCP Tasks extension. + /// The returned functions transparently poll task-backed calls to completion and also accept + /// ordinary inline results from servers that do not create a task. /// /// The connected MCP client. /// - /// Options that control the task lifecycle for task-capable tools. - /// When , defaults described on apply. + /// Options that control the task lifecycle. When , defaults described + /// on apply. /// /// Token used to cancel listing the server's tools. /// The tools, ready to pass to AsAIAgent(tools: …). - public static async Task> ListAgentToolsWithTaskSupportAsync( + /// + /// specifies a non-positive lifecycle limit. + /// + public static async Task> 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 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; diff --git a/dotnet/src/Microsoft.Agents.AI.Mcp/McpTaskOptions.cs b/dotnet/src/Microsoft.Agents.AI.Mcp/McpTaskOptions.cs index 930cf6427..213442a8f 100644 --- a/dotnet/src/Microsoft.Agents.AI.Mcp/McpTaskOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Mcp/McpTaskOptions.cs @@ -5,35 +5,65 @@ using System; namespace Microsoft.Agents.AI.Mcp; /// -/// Configures how an MCP client wrapper drives the -/// MCP tasks -/// lifecycle when an underlying server tool returns a CreateTaskResult. +/// Configures how task-aware MCP tools drive the +/// MCP Tasks extension +/// lifecycle. /// -/// -/// -/// All members of this type are subject to change. The MCP task surface is experimental -/// and tracks the in-flight specification. -/// -/// public sealed class McpTaskOptions { /// - /// 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 tasks/cancel request. /// + /// The default is five seconds. /// - /// When the wrapper omits the ttl 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. /// - public TimeSpan? DefaultTimeToLive { get; set; } + public TimeSpan RemoteCancellationTimeout { get; set; } = TimeSpan.FromSeconds(5); /// - /// Gets or sets a value indicating whether the wrapper should send - /// tasks/cancel when the local - /// fires during a tool invocation. + /// Gets or sets the minimum server-provided polling interval accepted by the client. + /// + /// The default is 10 milliseconds. + /// The value must be positive and not exceed . + public TimeSpan MinimumPollingInterval { get; set; } = TimeSpan.FromMilliseconds(10); + + /// + /// Gets or sets the maximum server-provided polling interval accepted by the client. + /// + /// The default is the maximum delay supported by the targeted .NET runtimes. + /// + /// The value must be at least and must not exceed + /// 4,294,967,294 milliseconds. + /// + public TimeSpan MaximumPollingInterval { get; set; } = + TimeSpan.FromMilliseconds(uint.MaxValue - 1L); + + /// + /// Gets or sets a value indicating whether local cancellation should send + /// tasks/cancel for a task-backed invocation. /// /// - /// Defaults to : a local cancellation means "the caller is giving up - /// on this tool invocation" and the server-side task has no further consumer. + /// Defaults to . Remote cancellation is best-effort and does not + /// replace the original local cancellation if the server cannot be reached. /// public bool CancelRemoteTaskOnLocalCancellation { get; set; } = true; + + /// + /// Gets or sets the number of consecutive input_required polls without new input + /// request keys allowed before the task is treated as stuck. + /// + /// The default is 60. + /// The value must be greater than zero. + public int MaxConsecutiveStuckPolls { get; set; } = 60; + + /// + /// Gets or sets the maximum number of unique input requests a task may publish. + /// + /// The default is 100. + /// + /// This per-task resource-safety limit bounds retained request keys and user or model + /// interactions. The value must be greater than zero. + /// + public int MaxTotalInputRequests { get; set; } = 100; } diff --git a/dotnet/src/Microsoft.Agents.AI.Mcp/Microsoft.Agents.AI.Mcp.csproj b/dotnet/src/Microsoft.Agents.AI.Mcp/Microsoft.Agents.AI.Mcp.csproj index 0e8d51bf0..788e88a60 100644 --- a/dotnet/src/Microsoft.Agents.AI.Mcp/Microsoft.Agents.AI.Mcp.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Mcp/Microsoft.Agents.AI.Mcp.csproj @@ -18,7 +18,7 @@ Microsoft Agent Framework MCP - Provides Microsoft Agent Framework support for Model Context Protocol (MCP), including long-running task (SEP-2663) integration for MCP clients. + Provides Microsoft Agent Framework support for Model Context Protocol (MCP), including MCP Tasks extension (SEP-2663) integration for MCP clients. @@ -30,6 +30,7 @@ + diff --git a/dotnet/src/Microsoft.Agents.AI.Mcp/TaskAwareMcpClientAIFunction.cs b/dotnet/src/Microsoft.Agents.AI.Mcp/TaskAwareMcpClientAIFunction.cs index 45ffdb4ce..7f31b762d 100644 --- a/dotnet/src/Microsoft.Agents.AI.Mcp/TaskAwareMcpClientAIFunction.cs +++ b/dotnet/src/Microsoft.Agents.AI.Mcp/TaskAwareMcpClientAIFunction.cs @@ -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; /// /// An wrapper around an that drives the -/// MCP long-running task -/// lifecycle (SEP-2663) on behalf of the agent's tool loop. +/// MCP Tasks extension +/// lifecycle on behalf of the agent's tool loop. /// /// /// -/// The wrapper invokes the tool with task augmentation via -/// , polls to completion via -/// , and fetches the result via -/// . The result is returned to the caller as a -/// containing the serialized — the -/// same wire shape produced by . -/// so that downstream 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. -/// -/// -/// This wrapper is intended to be applied only to tools whose -/// is -/// (selected by ). -/// As a defensive fallback, if the server still rejects the task-augmented call with -/// (e.g. because tool-level capabilities changed -/// between tools/list and invocation), the wrapper transparently falls back to a -/// non-augmented call through the inner . +/// The wrapper uses the public MCP Tasks extension primitives to retain the created task handle, +/// poll to completion, resolve input_required requests, and cancel remote work when the +/// local invocation is cancelled. Its result projection matches so +/// the agent's function-calling loop is unaware whether the server used a task. /// /// 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); } /// @@ -78,70 +80,239 @@ internal sealed class TaskAwareMcpClientAIFunction : AIFunction { _ = Throw.IfNull(arguments); - McpTaskMetadata? metadata = null; - if (this._options.DefaultTimeToLive is TimeSpan ttl) + ResultOrCreatedTask 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 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 PollTaskToCompletionAsync( + CreateTaskResult createdTask, + CancellationToken cancellationToken) { + string taskId = createdTask.TaskId; + long pollIntervalMs = createdTask.PollIntervalMs ?? + Math.Clamp(DefaultPollIntervalMs, this._minimumPollIntervalMs, this._maximumPollIntervalMs); + HashSet? 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()) + ?? 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 newRequests = []; + int observedCount = observedInputRequestKeys?.Count ?? 0; + int remainingInputRequests = this._maxTotalInputRequests - observedCount; + if (inputRequired.InputRequests is { } incomingRequests) + { + foreach (KeyValuePair 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 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()); + } 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 ToArgumentsDictionary( + AIFunctionArguments arguments, + JsonSerializerOptions options) + { + var typeInfo = options.GetTypeInfo(); + Dictionary result = new(arguments.Count); + foreach (KeyValuePair 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 property in metadata) + { + if (!string.Equals(property.Key, MetaKeys.ServerInfo, StringComparison.Ordinal)) + { + return true; + } + } + + return false; + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Mcp.UnitTests/EmptyServiceProvider.cs b/dotnet/tests/Microsoft.Agents.AI.Mcp.UnitTests/EmptyServiceProvider.cs deleted file mode 100644 index c2d74acf6..000000000 --- a/dotnet/tests/Microsoft.Agents.AI.Mcp.UnitTests/EmptyServiceProvider.cs +++ /dev/null @@ -1,15 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; - -namespace Microsoft.Agents.AI.Mcp.UnitTests; - -/// -/// Minimal empty for in-memory fixtures that don't use DI. -/// -internal sealed class EmptyServiceProvider : IServiceProvider -{ - public static EmptyServiceProvider Instance { get; } = new(); - - public object? GetService(Type serviceType) => null; -} diff --git a/dotnet/tests/Microsoft.Agents.AI.Mcp.UnitTests/InMemoryMcpServerFixture.cs b/dotnet/tests/Microsoft.Agents.AI.Mcp.UnitTests/InMemoryMcpServerFixture.cs index 0fba44433..22de5419c 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Mcp.UnitTests/InMemoryMcpServerFixture.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Mcp.UnitTests/InMemoryMcpServerFixture.cs @@ -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; /// 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 CreateAsync( McpServerPrimitiveCollection 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(); 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 _firstPollObserved = + new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _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? InputResponseReceived + { + add => this._inner.InputResponseReceived += value; + remove => this._inner.InputResponseReceived -= value; + } + + public async Task 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 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 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 inputRequests, + CancellationToken cancellationToken = default) + { + _ = Interlocked.Add(ref this._inputRequestCount, inputRequests.Count); + return this._inner.SetInputRequestsAsync(taskId, inputRequests, cancellationToken); + } + + public Task ResolveInputRequestsAsync( + string taskId, + IDictionary 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); + } + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Mcp.UnitTests/ListAgentToolsWithTaskSupportTests.cs b/dotnet/tests/Microsoft.Agents.AI.Mcp.UnitTests/ListAgentToolsWithTaskSupportTests.cs deleted file mode 100644 index 56544a44f..000000000 --- a/dotnet/tests/Microsoft.Agents.AI.Mcp.UnitTests/ListAgentToolsWithTaskSupportTests.cs +++ /dev/null @@ -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 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("Required tools must be wrapped"); - opt.Should().NotBeOfType("Optional tools must not be wrapped; inline invocation is preserved by default"); - forb.Should().NotBeOfType("Forbidden tools must not be wrapped"); - none.Should().NotBeOfType("Tools without execution metadata must not be wrapped"); - } - - [Fact] - public async Task ListAgentToolsWithTaskSupport_ThrowsOnNullClientAsync() - { - // Arrange - ModelContextProtocol.Client.McpClient client = null!; - - // Act - Func act = async () => await client.ListAgentToolsWithTaskSupportAsync(); - - // Assert - await act.Should().ThrowAsync(); - } -} diff --git a/dotnet/tests/Microsoft.Agents.AI.Mcp.UnitTests/ListAgentToolsWithTasksTests.cs b/dotnet/tests/Microsoft.Agents.AI.Mcp.UnitTests/ListAgentToolsWithTasksTests.cs new file mode 100644 index 000000000..1c3c96950 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Mcp.UnitTests/ListAgentToolsWithTasksTests.cs @@ -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 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(); + result.Select(tool => tool.Name).Should().Equal("first", "second"); + } + + [Fact] + public async Task ListAgentToolsWithTasks_ThrowsOnNullClientAsync() + { + // Arrange + ModelContextProtocol.Client.McpClient client = null!; + + // Act + Func act = async () => await client.ListAgentToolsWithTasksAsync(); + + // Assert + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task ListAgentToolsWithTasks_NonPositiveStuckPollLimit_ThrowsAsync() + { + // Arrange + McpServerPrimitiveCollection tools = [ + TestTools.Create("tool", () => "result"), + ]; + await using InMemoryMcpServerFixture fixture = await InMemoryMcpServerFixture.CreateAsync(tools); + var options = new McpTaskOptions { MaxConsecutiveStuckPolls = 0 }; + + // Act + Func act = async () => await fixture.Client.ListAgentToolsWithTasksAsync(options); + + // Assert + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task ListAgentToolsWithTasks_NonPositiveInputRequestLimit_ThrowsAsync() + { + // Arrange + McpServerPrimitiveCollection tools = [ + TestTools.Create("tool", () => "result"), + ]; + await using InMemoryMcpServerFixture fixture = await InMemoryMcpServerFixture.CreateAsync(tools); + var options = new McpTaskOptions { MaxTotalInputRequests = 0 }; + + // Act + Func act = async () => await fixture.Client.ListAgentToolsWithTasksAsync(options); + + // Assert + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task ListAgentToolsWithTasks_NonPositiveCancellationTimeout_ThrowsAsync() + { + // Arrange + McpServerPrimitiveCollection tools = [ + TestTools.Create("tool", () => "result"), + ]; + await using InMemoryMcpServerFixture fixture = await InMemoryMcpServerFixture.CreateAsync(tools); + var options = new McpTaskOptions { RemoteCancellationTimeout = TimeSpan.Zero }; + + // Act + Func act = async () => await fixture.Client.ListAgentToolsWithTasksAsync(options); + + // Assert + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task ListAgentToolsWithTasks_SubMillisecondCancellationTimeout_ThrowsAsync() + { + // Arrange + McpServerPrimitiveCollection tools = [ + TestTools.Create("tool", () => "result"), + ]; + await using InMemoryMcpServerFixture fixture = await InMemoryMcpServerFixture.CreateAsync(tools); + var options = new McpTaskOptions { RemoteCancellationTimeout = TimeSpan.FromTicks(1) }; + + // Act + Func act = async () => await fixture.Client.ListAgentToolsWithTasksAsync(options); + + // Assert + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task ListAgentToolsWithTasks_InvalidPollingIntervalRange_ThrowsAsync() + { + // Arrange + McpServerPrimitiveCollection 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 act = async () => await fixture.Client.ListAgentToolsWithTasksAsync(options); + + // Assert + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task ListAgentToolsWithTasks_PollingRangeWithoutWholeMillisecond_ThrowsAsync() + { + // Arrange + McpServerPrimitiveCollection 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 act = async () => await fixture.Client.ListAgentToolsWithTasksAsync(options); + + // Assert + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task ListAgentToolsWithTasks_PollingMaximumAboveRuntimeLimit_ThrowsAsync() + { + // Arrange + McpServerPrimitiveCollection tools = [ + TestTools.Create("tool", () => "result"), + ]; + await using InMemoryMcpServerFixture fixture = await InMemoryMcpServerFixture.CreateAsync(tools); + var options = new McpTaskOptions + { + MaximumPollingInterval = TimeSpan.FromMilliseconds(uint.MaxValue), + }; + + // Act + Func act = async () => await fixture.Client.ListAgentToolsWithTasksAsync(options); + + // Assert + await act.Should().ThrowAsync(); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Mcp.UnitTests/McpTaskOptionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Mcp.UnitTests/McpTaskOptionsTests.cs index 918b34ca1..19b3eee9b 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Mcp.UnitTests/McpTaskOptionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Mcp.UnitTests/McpTaskOptionsTests.cs @@ -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)); } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Mcp.UnitTests/Microsoft.Agents.AI.Mcp.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Mcp.UnitTests/Microsoft.Agents.AI.Mcp.UnitTests.csproj index e785b1076..d5763b5e3 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Mcp.UnitTests/Microsoft.Agents.AI.Mcp.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.Mcp.UnitTests/Microsoft.Agents.AI.Mcp.UnitTests.csproj @@ -11,6 +11,7 @@ + diff --git a/dotnet/tests/Microsoft.Agents.AI.Mcp.UnitTests/TaskAwareMcpClientAIFunctionTests.cs b/dotnet/tests/Microsoft.Agents.AI.Mcp.UnitTests/TaskAwareMcpClientAIFunctionTests.cs index 309fece74..354e66390 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Mcp.UnitTests/TaskAwareMcpClientAIFunctionTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Mcp.UnitTests/TaskAwareMcpClientAIFunctionTests.cs @@ -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 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(); + 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().Subject; - ExtractTextContent(payload).Should().Be("required-result"); + result.Should().BeOfType() + .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 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 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(TaskCreationOptions.RunContinuationsAsynchronously); - McpServerTool tool = McpServerTool.Create( - async (CancellationToken ct) => - { - try + // Arrange + McpServerPrimitiveCollection 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 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 invocation = wrapped.InvokeAsync(arguments: null, cts.Token).AsTask(); - await Task.Delay(200); - cts.Cancel(); - - // Assert — wrapper observes cancellation and signals server-side cancellation. - Func awaitInvocation = async () => await invocation; - await awaitInvocation.Should().ThrowAsync(); - - // 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)(() => throw new InvalidOperationException("simulated tool failure")), - new McpServerToolCreateOptions - { - Name = "boom", - Description = "Throws unconditionally.", - Execution = new ToolExecution { TaskSupport = ToolTaskSupport.Required }, - }); - McpServerPrimitiveCollection 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 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().Where(ex => - ex is InvalidOperationException - || ex.GetType().FullName == "ModelContextProtocol.McpException"); + // Assert + await act.Should().ThrowAsync() + .WithMessage($"*pollIntervalMs of {pollIntervalMs}*"); + await fixture.RemoteCancellationObserved.WaitAsync(TimeSpan.FromSeconds(5)); + fixture.SuccessfulCancellationTransitionCount.Should().Be(1); + fixture.CancellationRequestCount.Should().Be(1); } - /// - /// Extracts the first text-content block from a serialized CallToolResult - /// (the JSON shape returned by the wrapper and by McpClientTool.InvokeAsync). - /// - 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 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 act = async () => await wrapped.InvokeAsync(arguments: null, CancellationToken.None); + + // Assert + await act.Should().ThrowAsync() + .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(TaskCreationOptions.RunContinuationsAsynchronously); + McpServerPrimitiveCollection 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 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().Which.Text.Should().Be("completed"); + } + finally + { + _ = releaseServer.TrySetResult(true); + } + } + + [Fact] + public async Task InvokeAsync_MissingPollInterval_ConstrainsFallbackToConfiguredRangeAsync() + { + // Arrange + var releaseServer = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + McpServerPrimitiveCollection 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 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().Which.Text.Should().Be("completed"); + } + finally + { + _ = releaseServer.TrySetResult(true); + } + } + + [Fact] + public async Task InvokeAsync_ServerWithoutTasks_ReturnsInlineResultAsync() + { + // Arrange + McpServerPrimitiveCollection 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() + .Which.Text.Should().Be("inline-result"); + fixture.CreatedTaskCount.Should().Be(0); + } + + [Fact] + public async Task InvokeAsync_InputRequired_DispatchesClientHandlerAsync() + { + // Arrange + McpServerPrimitiveCollection 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( + new ElicitResult + { + Action = "accept", + Content = new Dictionary + { + ["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() + .Which.Text.Should().Be("accept:yes"); + fixture.CreatedTaskCount.Should().Be(1); + fixture.InputRequestCount.Should().Be(1); + } + + [Fact] + public async Task InvokeAsync_ForwardsNullPrimitiveAndComplexArgumentsAsync() + { + // Arrange + IDictionary? observedArguments = null; + McpServerPrimitiveCollection tools = [ + McpServerTool.Create( + (RequestContext 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 { ["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 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 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().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(TaskCreationOptions.RunContinuationsAsynchronously); + McpServerPrimitiveCollection 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 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 act = async () => await invocation; + + // Assert + await act.Should().ThrowAsync() + .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(TaskCreationOptions.RunContinuationsAsynchronously); + McpServerPrimitiveCollection 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 invocation = wrapped.InvokeAsync(arguments: null, CancellationToken.None).AsTask(); + + try + { + await fixture.FirstPollObserved.WaitAsync(TimeSpan.FromSeconds(5)); + await fixture.CancelLatestTaskAsync(); + + // Act + Func act = async () => await invocation; + + // Assert + await act.Should().ThrowAsync() + .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 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 act = async () => await wrapped.InvokeAsync(arguments: null, CancellationToken.None); + + // Assert + await act.Should().ThrowAsync() + .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 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 act = async () => await wrapped.InvokeAsync(arguments: null, CancellationToken.None); + + // Assert + await act.Should().ThrowAsync() + .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 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(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 act = async () => await wrapped.InvokeAsync(arguments: null, CancellationToken.None); + + // Assert + await act.Should().ThrowAsync() + .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(TaskCreationOptions.RunContinuationsAsynchronously); + McpServerPrimitiveCollection 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 invocation = wrapped.InvokeAsync(arguments: null, CancellationToken.None).AsTask(); + + try + { + await fixture.FirstPollObserved.WaitAsync(TimeSpan.FromSeconds(5)); + await fixture.CompleteLatestTaskAsync(JsonSerializer.SerializeToElement("malformed")); + + // Act + Func act = async () => await invocation; + + // Assert + await act.Should().ThrowAsync(); + fixture.SuccessfulCancellationTransitionCount.Should().Be(0); + fixture.CancellationRequestCount.Should().Be(0); + } + finally + { + _ = releaseServer.TrySetResult(true); + } + } + + [Fact] + public async Task InvokeAsync_StuckInputRequired_CancelsRemoteTaskAsync() + { + // Arrange + McpServerPrimitiveCollection 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(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 act = async () => await wrapped.InvokeAsync(arguments: null, CancellationToken.None); + + // Assert + await act.Should().ThrowAsync() + .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 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(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().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 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(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 act = async () => await wrapped.InvokeAsync(arguments: null, CancellationToken.None); + + // Assert + await act.Should().ThrowAsync() + .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(TaskCreationOptions.RunContinuationsAsynchronously); + McpServerPrimitiveCollection 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 invocation = wrapped.InvokeAsync(arguments: null, cts.Token).AsTask(); + await fixture.FirstPollObserved.WaitAsync(TimeSpan.FromSeconds(5)); + + // Act + cts.Cancel(); + Func act = async () => await invocation; + + // Assert + await act.Should().ThrowAsync(); + 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(TaskCreationOptions.RunContinuationsAsynchronously); + McpServerPrimitiveCollection 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 invocation = wrapped.InvokeAsync(arguments: null, cts.Token).AsTask(); + + try + { + await fixture.FirstPollObserved.WaitAsync(TimeSpan.FromSeconds(5)); + + // Act + cts.Cancel(); + Func act = async () => await invocation; + + // Assert + await act.Should().ThrowAsync(); + fixture.SuccessfulCancellationTransitionCount.Should().Be(0); + fixture.CancellationRequestCount.Should().Be(0); + } + finally + { + _ = releaseServer.TrySetResult(true); + } + } + + private static async Task WaitUntilAsync(Func predicate, TimeSpan timeout) + { + using var cts = new CancellationTokenSource(timeout); + while (!predicate()) + { + await Task.Delay(TimeSpan.FromMilliseconds(10), cts.Token); + } } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Mcp.UnitTests/TestTools.cs b/dotnet/tests/Microsoft.Agents.AI.Mcp.UnitTests/TestTools.cs index ef8780a33..7aaac7fb3 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Mcp.UnitTests/TestTools.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Mcp.UnitTests/TestTools.cs @@ -1,30 +1,21 @@ // Copyright (c) Microsoft. All rights reserved. using System; -using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; namespace Microsoft.Agents.AI.Mcp.UnitTests; /// -/// Helpers to create instances with a specific -/// level for in-memory fixtures. +/// Helpers to create instances for in-memory fixtures. /// 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}.", + }); } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.Mcp.UnitTests/DefaultMcpToolHandlerTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.Mcp.UnitTests/DefaultMcpToolHandlerTests.cs index 8aa559e87..d6471e5d5 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.Mcp.UnitTests/DefaultMcpToolHandlerTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.Mcp.UnitTests/DefaultMcpToolHandlerTests.cs @@ -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()