Add initial task extension conformance coverage (#1734)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 7c99928c-a943-49f4-9a63-8b04a0e4d18b
This commit is contained in:
Pranav Senthilnathan 2026-07-24 17:16:20 -07:00 • committed by GitHub
commit 83523b0a42
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 463 additions and 43 deletions

View file

@ -535,6 +535,16 @@ public static class NodeHelpers
"input-required-result-ignore-extra-params",
"input-required-result-validate-input");
/// <summary>
/// Checks whether the SEP-2663 Tasks extension server scenarios are available in the
/// installed conformance package.
/// </summary>
public static bool HasTasksExtensionScenarios()
=> HasInstalledConformanceScenarios(
"tasks-wire-fields",
"tasks-request-state-removal",
"tasks-mrtr-input");
private static ProcessStartInfo NpmStartInfo(string arguments, string workingDirectory)
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))

View file

@ -8,6 +8,7 @@ using System.Net;
using System.Net.ServerSentEvents;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace ModelContextProtocol.AspNetCore.Tests;
@ -32,6 +33,18 @@ public class HttpHeaderConformanceTests(ITestOutputHelper outputHelper) : Kestre
Name = nameof(HttpHeaderConformanceTests),
Version = "1.0",
};
#pragma warning disable MCPEXP002
options.RequestHandlers =
[
new McpServerRequestHandler
{
Method = "extension/get",
RoutingNameParameter = "itemId",
Handler = static (_, _) => new ValueTask<JsonNode?>(
new JsonObject { ["resultType"] = "complete" }),
},
];
#pragma warning restore MCPEXP002
}).WithTools(Tools).WithHttpTransport();
_app = Builder.Build();
@ -110,6 +123,23 @@ public class HttpHeaderConformanceTests(ITestOutputHelper outputHelper) : Kestre
#region Server-side validation tests
[Fact]
public async Task Server_UsesCustomHandlerRoutingNameMetadata()
{
await StartAsync();
await ProbeWithJuly2026ProtocolVersionAsync();
using var request = new HttpRequestMessage(HttpMethod.Post, "");
request.Content = JsonContent(
"""{"jsonrpc":"2.0","id":2,"method":"extension/get","params":{"itemId":"item-42","_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"TestClient","version":"1.0"},"io.modelcontextprotocol/clientCapabilities":{}}}}""");
request.Headers.Add(McpHttpHeaders.ProtocolVersion, "2026-07-28");
request.Headers.Add(McpHttpHeaders.Method, "extension/get");
request.Headers.Add(McpHttpHeaders.Name, "item-42");
using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
[Fact]
public async Task Server_AcceptsUnionIntegerCanonicalForm()
{

View file

@ -127,6 +127,34 @@ public class ServerConformanceTests(
$"MRTR conformance test '{scenario}' failed.\n\nStdout:\n{result.Output}\n\nStderr:\n{result.Error}");
}
[Theory]
[InlineData("tasks-wire-fields")]
[InlineData("tasks-request-state-removal")]
[InlineData("tasks-mrtr-input")]
// Most remaining scenarios require per-tool task execution configuration that the SDK
// does not currently expose; status notifications await an upstream harness rewrite.
// Keep them listed here for incremental enablement.
// [InlineData("tasks-lifecycle")]
// [InlineData("tasks-capability-negotiation")]
// [InlineData("tasks-request-headers")]
// [InlineData("tasks-dispatch-and-envelope")]
// [InlineData("tasks-status-notifications")]
// [InlineData("tasks-required-task-error")]
// [InlineData("tasks-mrtr-composition")]
public async Task RunTasksExtensionConformanceTest(string scenario)
{
Assert.SkipWhen(!NodeHelpers.IsNodeInstalled(), "Node.js is not installed. Skipping conformance tests.");
Assert.SkipWhen(
!NodeHelpers.HasTasksExtensionScenarios(),
"SEP-2663 Tasks extension scenarios are not available in the installed conformance package.");
var result = await RunStatelessConformanceTestAsync(
$"server --url {fixture.StatelessServerUrl} --scenario {scenario}");
Assert.True(result.Success,
$"Tasks extension conformance test '{scenario}' failed.\n\nStdout:\n{result.Output}\n\nStderr:\n{result.Error}");
}
private async Task<(bool Success, string Output, string Error)> RunConformanceTestsAsync(string arguments)
{
return await NodeHelpers.RunServerConformanceAsync(

View file

@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Http.Json;
using Microsoft.Extensions.DependencyInjection;
using ModelContextProtocol.AspNetCore.Tests.Utils;
using ModelContextProtocol.Client;
using ModelContextProtocol.Extensions.Tasks;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using ModelContextProtocol.Tests.Utils;
@ -11,6 +12,7 @@ using System.Net;
using System.Threading;
using System.Threading.Tasks;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization.Metadata;
namespace ModelContextProtocol.AspNetCore.Tests;
@ -606,6 +608,31 @@ public class StreamableHttpClientConformanceTests(ITestOutputHelper outputHelper
Assert.Equal("us-west-2", capturedHeaders["Mcp-Param-Region"]);
}
[Fact]
public async Task TasksClient_SendsRoutingNameHeader_EndToEnd()
{
var capturedHeaders = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
await StartHeaderCapturingServer(capturedHeaders, supportsTasks: true);
await using var transport = new HttpClientTransport(new()
{
Endpoint = new("http://localhost:5000/mcp"),
TransportMode = HttpTransportMode.StreamableHttp,
}, HttpClient, LoggerFactory);
await using var client = await McpClient.CreateAsync(
transport,
new McpClientOptions { ProtocolVersion = "2026-07-28" },
loggerFactory: LoggerFactory,
cancellationToken: TestContext.Current.CancellationToken);
capturedHeaders.Clear();
await client.GetTaskAsync("task-42", TestContext.Current.CancellationToken);
Assert.Equal("tasks/get", capturedHeaders[McpHttpHeaders.Method]);
Assert.Equal("task-42", capturedHeaders[McpHttpHeaders.Name]);
}
private async Task StartHeaderToolServer()
{
Builder.Services.Configure<JsonOptions>(options =>
@ -683,7 +710,9 @@ public class StreamableHttpClientConformanceTests(ITestOutputHelper outputHelper
await _app.StartAsync(TestContext.Current.CancellationToken);
}
private async Task StartHeaderCapturingServer(Dictionary<string, string> capturedHeaders)
private async Task StartHeaderCapturingServer(
Dictionary<string, string> capturedHeaders,
bool supportsTasks = false)
{
Builder.Services.Configure<JsonOptions>(options =>
{
@ -706,12 +735,45 @@ public class StreamableHttpClientConformanceTests(ITestOutputHelper outputHelper
Result = JsonSerializer.SerializeToNode(new InitializeResult
{
ProtocolVersion = "2025-11-25",
Capabilities = new() { Tools = new() },
Capabilities = new()
{
Tools = new(),
Extensions = supportsTasks
? new Dictionary<string, object>
{
["io.modelcontextprotocol/tasks"] = new JsonObject(),
}
: null,
},
ServerInfo = new Implementation { Name = "header-capture", Version = "1.0" },
}, McpJsonUtilities.DefaultOptions)
});
}
if (request.Method == "server/discover" && supportsTasks)
{
return Results.Json(new JsonRpcResponse
{
Id = request.Id,
Result = JsonSerializer.SerializeToNode(new DiscoverResult
{
SupportedVersions = ["2026-07-28"],
Capabilities = new()
{
Tools = new(),
Extensions = new Dictionary<string, object>
{
["io.modelcontextprotocol/tasks"] = new JsonObject(),
},
},
ServerInfo = new Implementation { Name = "header-capture", Version = "1.0" },
TimeToLive = TimeSpan.Zero,
CacheScope = CacheScope.Private,
ResultType = "complete",
}, McpJsonUtilities.DefaultOptions),
});
}
if (request.Method == "tools/list")
{
return Results.Json(new JsonRpcResponse
@ -732,7 +794,7 @@ public class StreamableHttpClientConformanceTests(ITestOutputHelper outputHelper
});
}
if (request.Method == "tools/call")
if (request.Method is "tools/call" or "tasks/get")
{
// Capture all MCP headers for verification
foreach (var header in context.Request.Headers)
@ -743,6 +805,16 @@ public class StreamableHttpClientConformanceTests(ITestOutputHelper outputHelper
}
}
if (request.Method == "tasks/get")
{
return Results.Json(new JsonRpcResponse
{
Id = request.Id,
Result = JsonNode.Parse(
"""{"taskId":"task-42","status":"working","createdAt":"2026-01-01T00:00:00Z","lastUpdatedAt":"2026-01-01T00:00:00Z"}"""),
});
}
var parameters = JsonSerializer.Deserialize(request.Params, GetJsonTypeInfo<CallToolRequestParams>());
return Results.Json(new JsonRpcResponse
{

View file

@ -15,6 +15,7 @@
<ItemGroup>
<ProjectReference Include="../../src/ModelContextProtocol.AspNetCore/ModelContextProtocol.AspNetCore.csproj" />
<ProjectReference Include="../../src/ModelContextProtocol.Extensions.Tasks/ModelContextProtocol.Extensions.Tasks.csproj" />
</ItemGroup>
</Project>

View file

@ -3,6 +3,7 @@ using ConformanceServer.Resources;
using ConformanceServer.Tools;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using ModelContextProtocol.Extensions.Tasks;
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Text.Json;
@ -52,7 +53,14 @@ public class Program
.AddMcpServer()
.WithHttpTransport(options => options.Stateless = stateless)
.WithDistributedCacheEventStreamStore()
.WithTasks(
new InMemoryMcpTaskStore
{
DefaultPollIntervalMs = 50,
DefaultTimeToLive = TimeSpan.FromMinutes(5),
})
.WithTools<ConformanceTools>()
.WithTools<ConformanceTaskTools>()
.WithTools<IncompleteResultTools>()
.WithTools([ConformanceTools.CreateJsonSchema202012Tool()])
.WithRequestFilters(filters => filters.AddCallToolFilter(next => async (request, cancellationToken) =>

View file

@ -0,0 +1,102 @@
#pragma warning disable MCPEXP001 // MRTR (SEP-2322) is experimental.
using ModelContextProtocol;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using System.ComponentModel;
namespace ConformanceServer.Tools;
[McpServerToolType]
public sealed class ConformanceTaskTools
{
[McpServerTool(Name = "greet")]
[Description("Returns a synchronous greeting.")]
public static string Greet(string name) => $"Hello, {name}!";
[McpServerTool(Name = "slow_compute")]
[Description("Completes after the requested number of seconds.")]
public static async Task<string> SlowCompute(int seconds, string? label, CancellationToken cancellationToken)
{
await Task.Delay(TimeSpan.FromSeconds(seconds), cancellationToken);
return $"Computed {label ?? "result"}";
}
[McpServerTool(Name = "failing_job")]
[Description("Produces a tool execution error.")]
public static async Task<string> FailingJob(CancellationToken cancellationToken)
{
await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken);
throw new Exception("The conformance task failed.");
}
[McpServerTool(Name = "protocol_error_job")]
[Description("Produces a protocol-level error.")]
public static string ProtocolErrorJob() =>
throw new McpProtocolException("The conformance task encountered a protocol error.", McpErrorCode.InternalError);
[McpServerTool(Name = "confirm_delete")]
[Description("Waits for elicitation before confirming deletion.")]
public static async Task<string> ConfirmDelete(
McpServer server,
string filename,
CancellationToken cancellationToken)
{
var result = await server.ElicitAsync(CreateConfirmationRequest($"Delete {filename}?"), cancellationToken);
return result.Action == "accept" ? $"Deleted {filename}" : $"Did not delete {filename}";
}
[McpServerTool(Name = "multi_input")]
[Description("Waits for two independent elicitation responses.")]
public static async Task<string> MultiInput(McpServer server, CancellationToken cancellationToken)
{
await Task.WhenAll(
server.ElicitAsync(CreateConfirmationRequest("Confirm the first operation."), cancellationToken).AsTask(),
server.ElicitAsync(CreateConfirmationRequest("Confirm the second operation."), cancellationToken).AsTask());
return "Both inputs received.";
}
[McpServerTool(Name = "test_tool_with_task")]
[Description("Collects input synchronously, then completes through a task.")]
public static string ToolWithTask(RequestContext<CallToolRequestParams> context)
{
if (context.Params!.InputResponses is { } responses &&
responses.TryGetValue("user_name", out var response))
{
var elicitation = response.Deserialize(InputResponse.ElicitResultJsonTypeInfo);
var name = elicitation?.Content?["name"].GetString() ?? "world";
return $"Hello, {name}!";
}
throw new InputRequiredException(
new Dictionary<string, InputRequest>
{
["user_name"] = InputRequest.ForElicitation(new ElicitRequestParams
{
Message = "What is your name?",
RequestedSchema = new ElicitRequestParams.RequestSchema
{
Properties =
{
["name"] = new ElicitRequestParams.StringSchema(),
},
Required = ["name"],
},
}),
});
}
private static ElicitRequestParams CreateConfirmationRequest(string message) =>
new()
{
Message = message,
RequestedSchema = new ElicitRequestParams.RequestSchema
{
Properties =
{
["confirm"] = new ElicitRequestParams.BooleanSchema(),
},
Required = ["confirm"],
},
};
}

View file

@ -154,9 +154,10 @@ public class McpClientTaskMethodsTests : ClientServerTestBase
{
await client.CancelTaskAsync(taskId, ct);
// If cancel succeeded, verify the task is cancelled
// Cancellation is eventually consistent. The task may complete before the cancellation
// request wins the race, but the terminal-task cancellation acknowledgement is idempotent.
var taskResult = await client.GetTaskAsync(taskId, ct);
Assert.IsType<CancelledTaskResult>(taskResult);
Assert.True(taskResult is CancelledTaskResult or CompletedTaskResult);
}
catch (McpProtocolException)
{

View file

@ -91,6 +91,18 @@ public static class TaskSerializationTests
#endregion
[Theory]
[InlineData(typeof(UpdateTaskResult))]
[InlineData(typeof(CancelTaskResult))]
public static void TaskAcknowledgement_SerializesExplicitCompleteResultType(Type resultType)
{
var result = (Result)Activator.CreateInstance(resultType)!;
var json = JsonSerializer.SerializeToNode(result, resultType, McpTasksJsonContext.Default.Options)!;
Assert.Equal("complete", (string)json["resultType"]!);
}
#region GetTaskResult Subtypes
[Fact]

View file

@ -75,5 +75,28 @@ public class CustomRequestHandlerCollisionTests(ITestOutputHelper testOutputHelp
await using var server = McpServer.Create(transport, options, LoggerFactory);
Assert.NotNull(server);
}
[Fact]
public async Task CustomHandler_EmptyRoutingNameParameter_Throws()
{
await using var transport = new StreamServerTransport(Stream.Null, Stream.Null);
var options = new McpServerOptions
{
RequestHandlers =
[
new McpServerRequestHandler
{
Method = "custom/method",
RoutingNameParameter = " ",
Handler = (request, cancellationToken) => new ValueTask<JsonNode?>((JsonNode?)null),
},
],
};
var ex = Assert.Throws<InvalidOperationException>(
() => McpServer.Create(transport, options, LoggerFactory));
Assert.Contains(nameof(McpServerRequestHandler.RoutingNameParameter), ex.Message);
}
#pragma warning restore MCPEXP002
}