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:
parent
f7c61fafdb
commit
83523b0a42
19 changed files with 463 additions and 43 deletions
|
|
@ -110,7 +110,7 @@ internal sealed class StreamableHttpHandler(
|
|||
return;
|
||||
}
|
||||
|
||||
if (!ValidateMcpHeaders(context, message, mcpServerOptionsSnapshot.Value.ToolCollection, out var errorMessage))
|
||||
if (!ValidateMcpHeaders(context, message, mcpServerOptionsSnapshot.Value, out var errorMessage))
|
||||
{
|
||||
await WriteJsonRpcErrorAsync(context, errorMessage, StatusCodes.Status400BadRequest, (int)McpErrorCode.HeaderMismatch, requestId);
|
||||
return;
|
||||
|
|
@ -829,10 +829,10 @@ internal sealed class StreamableHttpHandler(
|
|||
/// </summary>
|
||||
/// <param name="context">The HTTP context containing the request headers.</param>
|
||||
/// <param name="message">The JSON-RPC message to validate against.</param>
|
||||
/// <param name="toolCollection">The tool collection to look up tool schemas for parameter header validation.</param>
|
||||
/// <param name="serverOptions">The server options containing tools and custom request routing metadata.</param>
|
||||
/// <param name="errorMessage">Set to the error message if validation fails; null otherwise.</param>
|
||||
/// <returns>True if validation passes; false otherwise.</returns>
|
||||
internal static bool ValidateMcpHeaders(HttpContext context, JsonRpcMessage message, McpServerPrimitiveCollection<McpServerTool>? toolCollection, [NotNullWhen(false)] out string? errorMessage)
|
||||
internal static bool ValidateMcpHeaders(HttpContext context, JsonRpcMessage message, McpServerOptions serverOptions, [NotNullWhen(false)] out string? errorMessage)
|
||||
{
|
||||
// Only validate for protocol versions that support standard headers.
|
||||
var protocolVersion = context.Request.Headers[McpProtocolVersionHeaderName].ToString();
|
||||
|
|
@ -871,8 +871,10 @@ internal sealed class StreamableHttpHandler(
|
|||
return false;
|
||||
}
|
||||
|
||||
// From here on, only validate resources/read, tools/call, and prompts/get requests
|
||||
if (mcpMethodInBody is not (RequestMethods.ToolsCall or RequestMethods.ResourcesRead or RequestMethods.PromptsGet))
|
||||
#pragma warning disable MCPEXP002
|
||||
var routingNameParameter = GetRoutingNameParameter(mcpMethodInBody, serverOptions.RequestHandlers);
|
||||
#pragma warning restore MCPEXP002
|
||||
if (routingNameParameter is null)
|
||||
{
|
||||
errorMessage = null;
|
||||
return true;
|
||||
|
|
@ -911,13 +913,7 @@ internal sealed class StreamableHttpHandler(
|
|||
JsonRpcNotification notification => notification.Params,
|
||||
_ => null,
|
||||
};
|
||||
var mcpNameInBody = mcpMethodInBody switch
|
||||
{
|
||||
RequestMethods.ToolsCall => GetJsonNodeStringProperty(bodyParams, "name"),
|
||||
RequestMethods.ResourcesRead => GetJsonNodeStringProperty(bodyParams, "uri"),
|
||||
RequestMethods.PromptsGet => GetJsonNodeStringProperty(bodyParams, "name"),
|
||||
_ => null,
|
||||
};
|
||||
var mcpNameInBody = GetJsonNodeStringProperty(bodyParams, routingNameParameter);
|
||||
|
||||
// Check that the header value matches the body value if the body value is present.
|
||||
if (!string.Equals(decodedMcpNameInHeader, mcpNameInBody, StringComparison.Ordinal))
|
||||
|
|
@ -927,7 +923,7 @@ internal sealed class StreamableHttpHandler(
|
|||
}
|
||||
|
||||
// Validate Mcp-Param-* custom headers against tool schema
|
||||
if (!ValidateCustomParamHeaders(context, message, toolCollection, out errorMessage))
|
||||
if (!ValidateCustomParamHeaders(context, message, serverOptions.ToolCollection, out errorMessage))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
|
@ -936,6 +932,38 @@ internal sealed class StreamableHttpHandler(
|
|||
return true;
|
||||
}
|
||||
|
||||
#pragma warning disable MCPEXP002
|
||||
private static string? GetRoutingNameParameter(
|
||||
string? method,
|
||||
IList<McpServerRequestHandler>? requestHandlers)
|
||||
{
|
||||
var builtInParameter = method switch
|
||||
{
|
||||
RequestMethods.ToolsCall or RequestMethods.PromptsGet => "name",
|
||||
RequestMethods.ResourcesRead => "uri",
|
||||
_ => null,
|
||||
};
|
||||
|
||||
if (builtInParameter is not null)
|
||||
{
|
||||
return builtInParameter;
|
||||
}
|
||||
|
||||
if (requestHandlers is not null)
|
||||
{
|
||||
foreach (var requestHandler in requestHandlers)
|
||||
{
|
||||
if (string.Equals(requestHandler.Method, method, StringComparison.Ordinal))
|
||||
{
|
||||
return requestHandler.RoutingNameParameter;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
#pragma warning restore MCPEXP002
|
||||
|
||||
/// <summary>
|
||||
/// Validates that all parameters annotated with <c>x-mcp-header</c> in the tool's input schema
|
||||
/// have corresponding <c>Mcp-Param-*</c> headers present in the request, and that any present
|
||||
|
|
|
|||
|
|
@ -592,7 +592,8 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa
|
|||
headers.Add(McpHttpHeaders.Method, method);
|
||||
|
||||
// Add Mcp-Name header for methods that target a specific named resource
|
||||
string? name = message switch
|
||||
#pragma warning disable MCPEXP002
|
||||
string? name = message.Context?.RoutingName ?? message switch
|
||||
{
|
||||
JsonRpcRequest { Method: RequestMethods.ToolsCall or RequestMethods.PromptsGet } request
|
||||
=> GetParamsStringProperty(request.Params, "name"),
|
||||
|
|
@ -600,6 +601,7 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa
|
|||
=> GetParamsStringProperty(request.Params, "uri"),
|
||||
_ => null,
|
||||
};
|
||||
#pragma warning restore MCPEXP002
|
||||
|
||||
if (name is not null)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using ModelContextProtocol.Server;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
|
|
@ -75,6 +76,17 @@ public sealed class JsonRpcMessageContext
|
|||
/// </remarks>
|
||||
public IDictionary<string, object?>? Items { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the routing name for this message.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Streamable HTTP transports emit this value in the <c>Mcp-Name</c> header. This enables
|
||||
/// extension methods to identify the named resource targeted by a request.
|
||||
/// </remarks>
|
||||
[Experimental(Experimentals.Subclassing_DiagnosticId, UrlFormat = Experimentals.Subclassing_Url)]
|
||||
[JsonIgnore]
|
||||
public string? RoutingName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the protocol version from the transport-level header (e.g. <c>Mcp-Protocol-Version</c>)
|
||||
/// that accompanied this JSON-RPC message.
|
||||
|
|
|
|||
|
|
@ -1011,6 +1011,12 @@ internal sealed partial class McpServerImpl : McpServer
|
|||
$"A custom request handler registered through {nameof(McpServerOptions)}.{nameof(McpServerOptions.RequestHandlers)} has a null or empty {nameof(McpServerRequestHandler.Method)}.");
|
||||
}
|
||||
|
||||
if (entry.RoutingNameParameter is not null && string.IsNullOrWhiteSpace(entry.RoutingNameParameter))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"A custom request handler registered through {nameof(McpServerOptions)}.{nameof(McpServerOptions.RequestHandlers)} has an empty {nameof(McpServerRequestHandler.RoutingNameParameter)}.");
|
||||
}
|
||||
|
||||
// Custom handlers are registered after all built-in handlers, so a method already present
|
||||
// belongs to a built-in method (e.g. initialize, tools/call) or an earlier custom handler.
|
||||
// Silently overwriting it would bypass the built-in handler's filters and protocol gating,
|
||||
|
|
|
|||
|
|
@ -24,6 +24,17 @@ public sealed class McpServerRequestHandler
|
|||
/// </summary>
|
||||
public required string Method { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the name of the top-level request parameter whose value is mirrored in the
|
||||
/// <c>Mcp-Name</c> HTTP routing header.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When set, Streamable HTTP servers require the request to include an <c>Mcp-Name</c>
|
||||
/// header whose decoded value matches the string value of this parameter.
|
||||
/// </remarks>
|
||||
[Experimental(Experimentals.Subclassing_DiagnosticId, UrlFormat = Experimentals.Subclassing_Url)]
|
||||
public string? RoutingNameParameter { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the handler function that processes incoming requests for the specified method.
|
||||
/// </summary>
|
||||
|
|
|
|||
|
|
@ -104,7 +104,7 @@ public static class McpTasksClientExtensions
|
|||
/// <summary>
|
||||
/// Retrieves a task using explicit request parameters.
|
||||
/// </summary>
|
||||
public static ValueTask<GetTaskResult> GetTaskAsync(
|
||||
public static async ValueTask<GetTaskResult> GetTaskAsync(
|
||||
this McpClient client,
|
||||
GetTaskRequestParams requestParams,
|
||||
CancellationToken cancellationToken = default)
|
||||
|
|
@ -118,11 +118,19 @@ public static class McpTasksClientExtensions
|
|||
#endif
|
||||
|
||||
ThrowIfTasksNotSupported(client, nameof(GetTaskAsync));
|
||||
return client.SendRequestAsync<GetTaskRequestParams, GetTaskResult>(
|
||||
requestParams = new GetTaskRequestParams
|
||||
{
|
||||
TaskId = requestParams.TaskId,
|
||||
Meta = GetMetaWithTaskCapability(requestParams.Meta),
|
||||
};
|
||||
JsonRpcRequest jsonRpcRequest = CreateTaskRequest(
|
||||
TasksProtocol.MethodTasksGet,
|
||||
requestParams,
|
||||
McpTasksJsonContext.Default.Options,
|
||||
cancellationToken: cancellationToken);
|
||||
JsonSerializer.SerializeToNode(requestParams, McpTasksJsonContext.Default.GetTaskRequestParams),
|
||||
requestParams.TaskId);
|
||||
|
||||
JsonRpcResponse response = await client.SendRequestAsync(jsonRpcRequest, cancellationToken).ConfigureAwait(false);
|
||||
return response.Result?.Deserialize(McpTasksJsonContext.Default.GetTaskResult)
|
||||
?? throw new JsonException("Unexpected JSON result in response.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -148,6 +156,7 @@ public static class McpTasksClientExtensions
|
|||
JsonObject paramsObj = new()
|
||||
{
|
||||
["taskId"] = requestParams.TaskId,
|
||||
["_meta"] = GetMetaWithTaskCapability(requestParams.Meta),
|
||||
};
|
||||
|
||||
if (requestParams.InputResponses is { Count: > 0 } inputResponses)
|
||||
|
|
@ -157,11 +166,10 @@ public static class McpTasksClientExtensions
|
|||
McpJsonUtilities.DefaultOptions.GetTypeInfo<IDictionary<string, InputResponse>>());
|
||||
}
|
||||
|
||||
JsonRpcRequest jsonRpcRequest = new()
|
||||
{
|
||||
Method = TasksProtocol.MethodTasksUpdate,
|
||||
Params = paramsObj,
|
||||
};
|
||||
JsonRpcRequest jsonRpcRequest = CreateTaskRequest(
|
||||
TasksProtocol.MethodTasksUpdate,
|
||||
paramsObj,
|
||||
requestParams.TaskId);
|
||||
|
||||
JsonRpcResponse response = await client.SendRequestAsync(jsonRpcRequest, cancellationToken).ConfigureAwait(false);
|
||||
return response.Result?.Deserialize(McpTasksJsonContext.Default.UpdateTaskResult)
|
||||
|
|
@ -190,7 +198,7 @@ public static class McpTasksClientExtensions
|
|||
/// <summary>
|
||||
/// Requests task cancellation using explicit request parameters.
|
||||
/// </summary>
|
||||
public static ValueTask<CancelTaskResult> CancelTaskAsync(
|
||||
public static async ValueTask<CancelTaskResult> CancelTaskAsync(
|
||||
this McpClient client,
|
||||
CancelTaskRequestParams requestParams,
|
||||
CancellationToken cancellationToken = default)
|
||||
|
|
@ -204,13 +212,32 @@ public static class McpTasksClientExtensions
|
|||
#endif
|
||||
|
||||
ThrowIfTasksNotSupported(client, nameof(CancelTaskAsync));
|
||||
return client.SendRequestAsync<CancelTaskRequestParams, CancelTaskResult>(
|
||||
requestParams = new CancelTaskRequestParams
|
||||
{
|
||||
TaskId = requestParams.TaskId,
|
||||
Meta = GetMetaWithTaskCapability(requestParams.Meta),
|
||||
};
|
||||
JsonRpcRequest jsonRpcRequest = CreateTaskRequest(
|
||||
TasksProtocol.MethodTasksCancel,
|
||||
requestParams,
|
||||
McpTasksJsonContext.Default.Options,
|
||||
cancellationToken: cancellationToken);
|
||||
JsonSerializer.SerializeToNode(requestParams, McpTasksJsonContext.Default.CancelTaskRequestParams),
|
||||
requestParams.TaskId);
|
||||
|
||||
JsonRpcResponse response = await client.SendRequestAsync(jsonRpcRequest, cancellationToken).ConfigureAwait(false);
|
||||
return response.Result?.Deserialize(McpTasksJsonContext.Default.CancelTaskResult)
|
||||
?? new CancelTaskResult();
|
||||
}
|
||||
|
||||
private static JsonRpcRequest CreateTaskRequest(string method, JsonNode? parameters, string taskId) =>
|
||||
new()
|
||||
{
|
||||
Method = method,
|
||||
Params = parameters,
|
||||
Context = new JsonRpcMessageContext
|
||||
{
|
||||
RoutingName = taskId,
|
||||
},
|
||||
};
|
||||
|
||||
private static async ValueTask<CallToolResult> PollTaskToCompletionAsync(
|
||||
McpClient client,
|
||||
CreateTaskResult taskCreated,
|
||||
|
|
|
|||
|
|
@ -20,4 +20,9 @@ namespace ModelContextProtocol.Extensions.Tasks;
|
|||
/// </remarks>
|
||||
public sealed class CancelTaskResult : Result
|
||||
{
|
||||
/// <summary>Initializes a new task cancellation acknowledgement.</summary>
|
||||
public CancelTaskResult()
|
||||
{
|
||||
ResultType = "complete";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,4 +19,9 @@ namespace ModelContextProtocol.Extensions.Tasks;
|
|||
/// </remarks>
|
||||
public sealed class UpdateTaskResult : Result
|
||||
{
|
||||
/// <summary>Initializes a new task update acknowledgement.</summary>
|
||||
public UpdateTaskResult()
|
||||
{
|
||||
ResultType = "complete";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -75,9 +75,24 @@ public static class McpTasksBuilderExtensions
|
|||
}
|
||||
|
||||
options.RequestHandlers ??= new List<McpServerRequestHandler>();
|
||||
options.RequestHandlers.Add(new McpServerRequestHandler { Method = TasksProtocol.MethodTasksGet, Handler = HandleGetTask });
|
||||
options.RequestHandlers.Add(new McpServerRequestHandler { Method = TasksProtocol.MethodTasksUpdate, Handler = HandleUpdateTask });
|
||||
options.RequestHandlers.Add(new McpServerRequestHandler { Method = TasksProtocol.MethodTasksCancel, Handler = HandleCancelTask });
|
||||
options.RequestHandlers.Add(new McpServerRequestHandler
|
||||
{
|
||||
Method = TasksProtocol.MethodTasksGet,
|
||||
RoutingNameParameter = "taskId",
|
||||
Handler = HandleGetTask,
|
||||
});
|
||||
options.RequestHandlers.Add(new McpServerRequestHandler
|
||||
{
|
||||
Method = TasksProtocol.MethodTasksUpdate,
|
||||
RoutingNameParameter = "taskId",
|
||||
Handler = HandleUpdateTask,
|
||||
});
|
||||
options.RequestHandlers.Add(new McpServerRequestHandler
|
||||
{
|
||||
Method = TasksProtocol.MethodTasksCancel,
|
||||
RoutingNameParameter = "taskId",
|
||||
Handler = HandleCancelTask,
|
||||
});
|
||||
|
||||
if (options.Filters.Request.CallToolFilters.Count > 0)
|
||||
{
|
||||
|
|
@ -93,7 +108,8 @@ public static class McpTasksBuilderExtensions
|
|||
options.Filters.Request.CallToolWithAlternateFilters.Count,
|
||||
async (request, next, cancellationToken) =>
|
||||
{
|
||||
if (!IsJuly2026OrLaterProtocolRequest(request.JsonRpcRequest) || !HasTaskExtensionOptIn(request.Params?.Meta))
|
||||
if (!IsJuly2026OrLaterProtocolRequest(request.JsonRpcRequest) ||
|
||||
!HasTaskExtensionOptIn(request.JsonRpcRequest))
|
||||
{
|
||||
return await next(request, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
|
@ -257,6 +273,7 @@ public static class McpTasksBuilderExtensions
|
|||
private async ValueTask<JsonNode?> HandleGetTask(JsonRpcRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
GateToJuly2026OrLaterProtocol(request, TasksProtocol.MethodTasksGet);
|
||||
GateToTasksCapability(request);
|
||||
|
||||
var requestParams = request.Params?.Deserialize(McpTasksJsonContext.Default.GetTaskRequestParams)
|
||||
?? throw new McpProtocolException("Missing params for tasks/get", McpErrorCode.InvalidParams);
|
||||
|
|
@ -273,6 +290,7 @@ public static class McpTasksBuilderExtensions
|
|||
private async ValueTask<JsonNode?> HandleUpdateTask(JsonRpcRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
GateToJuly2026OrLaterProtocol(request, TasksProtocol.MethodTasksUpdate);
|
||||
GateToTasksCapability(request);
|
||||
|
||||
var taskId = request.Params?["taskId"]?.GetValue<string>()
|
||||
?? throw new McpProtocolException("Missing params.taskId for tasks/update", McpErrorCode.InvalidParams);
|
||||
|
|
@ -292,6 +310,7 @@ public static class McpTasksBuilderExtensions
|
|||
private async ValueTask<JsonNode?> HandleCancelTask(JsonRpcRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
GateToJuly2026OrLaterProtocol(request, TasksProtocol.MethodTasksCancel);
|
||||
GateToTasksCapability(request);
|
||||
|
||||
var requestParams = request.Params?.Deserialize(McpTasksJsonContext.Default.CancelTaskRequestParams)
|
||||
?? throw new McpProtocolException("Missing params for tasks/cancel", McpErrorCode.InvalidParams);
|
||||
|
|
@ -319,11 +338,29 @@ public static class McpTasksBuilderExtensions
|
|||
}
|
||||
}
|
||||
|
||||
private static bool HasTaskExtensionOptIn(JsonObject? meta) =>
|
||||
meta is not null &&
|
||||
meta[MetaKeys.ClientCapabilities] is JsonObject caps &&
|
||||
caps["extensions"] is JsonObject exts &&
|
||||
exts.ContainsKey(TasksProtocol.ExtensionId);
|
||||
private static void GateToTasksCapability(JsonRpcRequest request)
|
||||
{
|
||||
if (!HasTaskExtensionOptIn(request))
|
||||
{
|
||||
throw CreateMissingTasksCapabilityException();
|
||||
}
|
||||
}
|
||||
|
||||
private static MissingRequiredClientCapabilityException CreateMissingTasksCapabilityException() =>
|
||||
new(
|
||||
new ClientCapabilities
|
||||
{
|
||||
Extensions = new Dictionary<string, object>
|
||||
{
|
||||
[TasksProtocol.ExtensionId] = new JsonObject(),
|
||||
},
|
||||
},
|
||||
$"The request requires the '{TasksProtocol.ExtensionId}' client extension capability.");
|
||||
|
||||
private static bool HasTaskExtensionOptIn(JsonRpcRequest request) =>
|
||||
request.Context?.ClientCapabilities?.Extensions?.ContainsKey(TasksProtocol.ExtensionId) is true ||
|
||||
request.Params?["_meta"]?[MetaKeys.ClientCapabilities]?["extensions"] is JsonObject extensions &&
|
||||
extensions.ContainsKey(TasksProtocol.ExtensionId);
|
||||
|
||||
private static bool IsJuly2026OrLaterProtocolRequest(JsonRpcRequest? request) =>
|
||||
McpProtocolVersions.IsJuly2026OrLaterProtocolVersion(request?.Context?.ProtocolVersion);
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue