diff --git a/docs/mcp-http/configuration.md b/docs/mcp-http/configuration.md
index be7cc6bc..eddc4c22 100644
--- a/docs/mcp-http/configuration.md
+++ b/docs/mcp-http/configuration.md
@@ -32,6 +32,14 @@ public gateway routing. HTTP MCP stores its isolated outbound configuration,
validates ingress according to an explicit mode and calls the exact reviewed API
target. Its default transport is HTTP serving, not interactive setup.
+Authenticated JSON posts are checked for duplicate tool argument envelopes before
+SDK binding, including streamed bodies without `Content-Length` and the trailing
+slash route. The check accepts bodies above 1 MiB: that value is only its memory
+buffering threshold, after which request-scoped temporary buffering is used.
+The server's finite request-body limit applies; where the hosting server supplies
+no finite limit, HTTP MCP uses 30,000,000 bytes. Larger bodies return HTTP `413`
+before tool execution, whether or not the client supplies `Content-Length`.
+
## Mode matrix
| Ingress mode | Client credential | Outbound `credentialMode` | Persistent downstream secret | Metadata |
diff --git a/src/Helpdesk.Mcp.Core/Tools/McpDuplicateArgumentsGuard.cs b/src/Helpdesk.Mcp.Core/Tools/McpDuplicateArgumentsGuard.cs
new file mode 100644
index 00000000..ced60c71
--- /dev/null
+++ b/src/Helpdesk.Mcp.Core/Tools/McpDuplicateArgumentsGuard.cs
@@ -0,0 +1,80 @@
+using System.Text.Json;
+
+namespace Helpdesk.Mcp.Tools;
+
+///
+/// Moves a duplicate tools/call params.arguments property to the envelope
+/// validator's already supported duplicate-operation path. The SDK binds
+/// params before invoking tools and otherwise turns this malformed request
+/// into an internal transport error.
+///
+public static class McpDuplicateArgumentsGuard
+{
+ public static bool TryNormalize(ReadOnlyMemory raw, out byte[] normalized)
+ {
+ normalized = [];
+ JsonDocument document;
+ try { document = JsonDocument.Parse(raw); }
+ catch (JsonException) { return false; }
+ using (document)
+ return TryNormalize(document.RootElement, out normalized);
+ }
+
+ public static async Task NormalizeAsync(Stream stream, CancellationToken cancellationToken = default)
+ {
+ JsonDocument document;
+ try { document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false); }
+ catch (JsonException) { return null; }
+ using (document)
+ return TryNormalize(document.RootElement, out var normalized) ? normalized : null;
+ }
+
+ private static bool TryNormalize(JsonElement root, out byte[] normalized)
+ {
+ normalized = [];
+ if (root.ValueKind != JsonValueKind.Object ||
+ !root.TryGetProperty("method", out var method) || method.ValueKind != JsonValueKind.String ||
+ method.GetString() != "tools/call" ||
+ !root.TryGetProperty("params", out var parameters) || parameters.ValueKind != JsonValueKind.Object ||
+ parameters.EnumerateObject().Count(property => property.NameEquals("arguments")) < 2)
+ return false;
+
+ using var buffer = new MemoryStream();
+ using (var writer = new Utf8JsonWriter(buffer))
+ {
+ writer.WriteStartObject();
+ foreach (var property in root.EnumerateObject())
+ {
+ if (!property.NameEquals("params") || property.Value.ValueKind != JsonValueKind.Object)
+ {
+ property.WriteTo(writer);
+ continue;
+ }
+
+ writer.WritePropertyName("params");
+ writer.WriteStartObject();
+ var wroteArguments = false;
+ foreach (var parameter in property.Value.EnumerateObject())
+ {
+ if (!parameter.NameEquals("arguments"))
+ {
+ parameter.WriteTo(writer);
+ continue;
+ }
+
+ if (wroteArguments) continue;
+ wroteArguments = true;
+ writer.WritePropertyName("arguments");
+ writer.WriteStartObject();
+ writer.WriteString("operation", "__duplicate_envelope__");
+ writer.WriteString("operation", "__duplicate_envelope__");
+ writer.WriteEndObject();
+ }
+ writer.WriteEndObject();
+ }
+ writer.WriteEndObject();
+ }
+ normalized = buffer.ToArray();
+ return true;
+ }
+}
diff --git a/src/Helpdesk.Mcp.Core/Tools/McpDuplicateArgumentsStream.cs b/src/Helpdesk.Mcp.Core/Tools/McpDuplicateArgumentsStream.cs
new file mode 100644
index 00000000..c5f232de
--- /dev/null
+++ b/src/Helpdesk.Mcp.Core/Tools/McpDuplicateArgumentsStream.cs
@@ -0,0 +1,62 @@
+using System.Text;
+
+namespace Helpdesk.Mcp.Tools;
+
+/// Filters newline-delimited stdio frames before SDK parameter binding.
+public sealed class McpDuplicateArgumentsStream(Stream input) : Stream
+{
+ private readonly StreamReader _reader = new(input, new UTF8Encoding(false), false, 4096, leaveOpen: true);
+ private byte[] _frame = [];
+ private int _offset;
+
+ public override bool CanRead => true;
+ public override bool CanSeek => false;
+ public override bool CanWrite => false;
+ public override long Length => throw new NotSupportedException();
+ public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); }
+
+ public override int Read(byte[] buffer, int offset, int count) => Read(buffer.AsSpan(offset, count));
+
+ public override int Read(Span buffer)
+ {
+ if (buffer.IsEmpty) return 0;
+ if (_offset == _frame.Length && !Load(_reader.ReadLine())) return 0;
+ var length = Math.Min(buffer.Length, _frame.Length - _offset);
+ _frame.AsSpan(_offset, length).CopyTo(buffer);
+ _offset += length;
+ return length;
+ }
+
+ public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default)
+ {
+ if (buffer.IsEmpty) return 0;
+ if (_offset == _frame.Length && !Load(await _reader.ReadLineAsync(cancellationToken).ConfigureAwait(false))) return 0;
+ var length = Math.Min(buffer.Length, _frame.Length - _offset);
+ _frame.AsMemory(_offset, length).CopyTo(buffer);
+ _offset += length;
+ return length;
+ }
+
+ private bool Load(string? line)
+ {
+ if (line is null) return false;
+ var raw = Encoding.UTF8.GetBytes(line);
+ if (McpDuplicateArgumentsGuard.TryNormalize(raw, out var normalized)) raw = normalized;
+ _frame = new byte[raw.Length + 1];
+ raw.CopyTo(_frame, 0);
+ _frame[^1] = (byte)'\n';
+ _offset = 0;
+ return true;
+ }
+
+ public override void Flush() => throw new NotSupportedException();
+ public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
+ public override void SetLength(long value) => throw new NotSupportedException();
+ public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
+
+ protected override void Dispose(bool disposing)
+ {
+ if (disposing) _reader.Dispose();
+ base.Dispose(disposing);
+ }
+}
diff --git a/src/Helpdesk.Mcp.Http/McpDuplicateArgumentsMiddleware.cs b/src/Helpdesk.Mcp.Http/McpDuplicateArgumentsMiddleware.cs
new file mode 100644
index 00000000..73b472e5
--- /dev/null
+++ b/src/Helpdesk.Mcp.Http/McpDuplicateArgumentsMiddleware.cs
@@ -0,0 +1,71 @@
+using Helpdesk.Mcp.Tools;
+using Microsoft.AspNetCore.Http.Features;
+
+namespace Helpdesk.Mcp.Http;
+
+/// Validates duplicate argument envelopes before the MCP SDK binds HTTP requests.
+public sealed class McpDuplicateArgumentsMiddleware(RequestDelegate next)
+{
+ private const int MemoryThreshold = 1_048_576;
+ private const long DefaultMaximumBodyBytes = 30_000_000;
+
+ public async Task InvokeAsync(HttpContext context)
+ {
+ var request = context.Request;
+ if (!string.Equals(request.Path.Value?.TrimEnd('/'), "/mcp", StringComparison.OrdinalIgnoreCase) ||
+ !HttpMethods.IsPost(request.Method) ||
+ request.ContentType?.StartsWith("application/json", StringComparison.OrdinalIgnoreCase) != true)
+ {
+ await next(context);
+ return;
+ }
+
+ // Content-Length is optional with streamed HTTP bodies. Inspect the entire
+ // accepted body, using the server's finite limit or its normal default.
+ var maximumBytes = context.Features.Get()?.MaxRequestBodySize
+ ?? DefaultMaximumBodyBytes;
+ if (request.ContentLength > maximumBytes)
+ {
+ context.Response.StatusCode = StatusCodes.Status413PayloadTooLarge;
+ return;
+ }
+
+ var bufferingLimit = maximumBytes < long.MaxValue ? maximumBytes + 1 : maximumBytes;
+ request.EnableBuffering(bufferThreshold: MemoryThreshold, bufferLimit: bufferingLimit);
+ var buffer = new byte[8192];
+ long inspectedBytes = 0;
+ try
+ {
+ while (true)
+ {
+ var remaining = maximumBytes - inspectedBytes;
+ var readLength = remaining < buffer.Length ? (int)remaining + 1 : buffer.Length;
+ var count = await request.Body.ReadAsync(buffer.AsMemory(0, readLength), context.RequestAborted);
+ if (count == 0) break;
+ inspectedBytes += count;
+ if (inspectedBytes > maximumBytes)
+ {
+ context.Response.StatusCode = StatusCodes.Status413PayloadTooLarge;
+ return;
+ }
+ }
+ }
+ catch (BadHttpRequestException error) when (error.StatusCode == StatusCodes.Status413PayloadTooLarge)
+ {
+ context.Response.StatusCode = StatusCodes.Status413PayloadTooLarge;
+ return;
+ }
+
+ request.Body.Position = 0;
+ var normalized = await McpDuplicateArgumentsGuard.NormalizeAsync(request.Body, context.RequestAborted);
+ request.Body.Position = 0;
+ if (normalized is not null)
+ {
+ var body = new MemoryStream(normalized, writable: false);
+ context.Response.RegisterForDispose(body);
+ request.Body = body;
+ request.ContentLength = normalized.Length;
+ }
+ await next(context);
+ }
+}
diff --git a/src/Helpdesk.Mcp.Http/Program.cs b/src/Helpdesk.Mcp.Http/Program.cs
index 345d0168..0a5f31f1 100644
--- a/src/Helpdesk.Mcp.Http/Program.cs
+++ b/src/Helpdesk.Mcp.Http/Program.cs
@@ -222,6 +222,7 @@ public static void Main(string[] args)
app.UseRateLimiter();
app.UseAuthentication();
app.UseAuthorization();
+ app.UseMiddleware();
app.MapHealthChecks("/health/live", new Microsoft.AspNetCore.Diagnostics.HealthChecks.HealthCheckOptions { Predicate = check => check.Tags.Contains("live") });
app.MapHealthChecks("/health/ready");
if (protectedResourceMetadata is not null)
diff --git a/src/Helpdesk.Mcp/Program.cs b/src/Helpdesk.Mcp/Program.cs
index bca29a9e..bb71def8 100644
--- a/src/Helpdesk.Mcp/Program.cs
+++ b/src/Helpdesk.Mcp/Program.cs
@@ -41,7 +41,7 @@ public static async Task Main(string[] args)
builder.Services.AddHelpdeskMcpCore(configuration, configurationSurface, target, "stdio");
builder.Services.AddMcpServer()
- .WithStdioServerTransport()
+ .WithStreamServerTransport(new McpDuplicateArgumentsStream(Console.OpenStandardInput()), Console.OpenStandardOutput())
.WithTools(HelpdeskToolDefinitions.Create())
.WithResourcesFromAssembly(typeof(HelpdeskResources).Assembly)
.WithPromptsFromAssembly(typeof(HelpdeskPrompts).Assembly);
diff --git a/tests/Helpdesk.Tests/Mcp/McpHttpBeta9RegressionTests.cs b/tests/Helpdesk.Tests/Mcp/McpHttpBeta9RegressionTests.cs
index aa631ddb..87adfc6e 100644
--- a/tests/Helpdesk.Tests/Mcp/McpHttpBeta9RegressionTests.cs
+++ b/tests/Helpdesk.Tests/Mcp/McpHttpBeta9RegressionTests.cs
@@ -59,6 +59,69 @@ public async Task Beta9_stdio_rejects_unknown_and_duplicate_outer_fields()
}
}
+ [Fact]
+ public async Task Duplicate_params_arguments_return_structured_validation_on_http_and_stdio_without_business_IO()
+ {
+ using var environment = new McpHostEnvironment();
+ var outbound = new AgentClientBoundaryProbe();
+ using var factory = CreateFactory(environment.SigningKey, boundaryProbe: outbound);
+ using var http = CreateClient(factory);
+ var bearer = CreateToken(environment.SigningKey, ["helpdesk.mcp"], ["ai-assistant"]);
+ await using var stdio = StdioMcpClient.Start();
+ using var initialized = await stdio.CallAsync(1, "initialize", InitializeParameters());
+ await stdio.NotifyAsync("notifications/initialized", new { });
+
+ const string raw = "{\"jsonrpc\":\"2.0\",\"id\":42,\"method\":\"tools/call\",\"params\":{\"name\":\"helpdesk_notifications\",\"arguments\":{\"operation\":\"summary\"},\"arguments\":{\"operation\":\"mark_read\",\"request\":{\"id\":\"synthetic\"},\"confirm\":true}}}";
+ using var request = new HttpRequestMessage(HttpMethod.Post, "/mcp")
+ {
+ Content = new StringContent(raw, System.Text.Encoding.UTF8, "application/json")
+ };
+ request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", bearer);
+ request.Headers.Accept.ParseAdd("application/json, text/event-stream");
+ using var httpResponse = await http.SendAsync(request);
+ Assert.Equal(System.Net.HttpStatusCode.OK, httpResponse.StatusCode);
+ using var httpResult = ParseMcpResponse(await httpResponse.Content.ReadAsStringAsync());
+ using var stdioResult = await stdio.CallRawAsync(raw);
+
+ foreach (var result in new[] { httpResult, stdioResult })
+ {
+ var content = result.RootElement.GetProperty("result").GetProperty("structuredContent");
+ Assert.Equal(42, result.RootElement.GetProperty("id").GetInt32());
+ Assert.Equal("validation_failed", content.GetProperty("status").GetString());
+ Assert.Equal("validation_error", content.GetProperty("failure").GetProperty("code").GetString());
+ Assert.True(result.RootElement.GetProperty("result").GetProperty("isError").GetBoolean());
+ }
+ Assert.Equal(0, outbound.ApiRequests);
+ Assert.Equal(0, outbound.TokenRequests);
+ }
+
+ [Fact]
+ public async Task Duplicate_params_arguments_rejects_after_gateway_authentication_without_execution_IO()
+ {
+ using var environment = new McpHostEnvironment(gateway: true);
+ var delegation = new GatewayDelegationProbe();
+ var outbound = new AgentClientBoundaryProbe();
+ using var factory = CreateFactory(environment.SigningKey, boundaryProbe: outbound, delegationProbe: delegation);
+ using var http = CreateClient(factory);
+ const string raw = "{\"jsonrpc\":\"2.0\",\"id\":43,\"method\":\"tools/call\",\"params\":{\"name\":\"helpdesk_notifications\",\"arguments\":{\"operation\":\"summary\"},\"arguments\":{\"operation\":\"mark_read\",\"confirm\":true}}}";
+ using var request = new HttpRequestMessage(HttpMethod.Post, "/mcp")
+ {
+ Content = new StringContent(raw, System.Text.Encoding.UTF8, "application/json")
+ };
+ request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", "rdk_local-a");
+ request.Headers.Accept.ParseAdd("application/json, text/event-stream");
+
+ using var response = await http.SendAsync(request);
+ Assert.Equal(System.Net.HttpStatusCode.OK, response.StatusCode);
+ using var result = ParseMcpResponse(await response.Content.ReadAsStringAsync());
+ var content = result.RootElement.GetProperty("result").GetProperty("structuredContent");
+ Assert.Equal(43, result.RootElement.GetProperty("id").GetInt32());
+ Assert.Equal("validation_failed", content.GetProperty("status").GetString());
+ Assert.Equal(1, delegation.Requests);
+ Assert.Equal(0, outbound.ApiRequests);
+ Assert.Equal(0, outbound.TokenRequests);
+ }
+
[Fact]
public async Task Beta9_HTTP_MCP_reads_prometheus_metrics_as_structured_text()
{
diff --git a/tests/Helpdesk.Tests/Mcp/McpHttpHostTests.cs b/tests/Helpdesk.Tests/Mcp/McpHttpHostTests.cs
index 99827af2..c7d78ff7 100644
--- a/tests/Helpdesk.Tests/Mcp/McpHttpHostTests.cs
+++ b/tests/Helpdesk.Tests/Mcp/McpHttpHostTests.cs
@@ -1923,6 +1923,15 @@ public async Task CallAsync(int id, string method, object paramete
return JsonDocument.Parse(line!);
}
+ public async Task CallRawAsync(string raw)
+ {
+ await _process.StandardInput.WriteLineAsync(raw);
+ await _process.StandardInput.FlushAsync();
+ var line = await _process.StandardOutput.ReadLineAsync().WaitAsync(TimeSpan.FromSeconds(10));
+ Assert.False(string.IsNullOrWhiteSpace(line), "The stdio MCP host did not respond to the raw request.");
+ return JsonDocument.Parse(line!);
+ }
+
public async Task NotifyAsync(string method, object parameters)
{
await _process.StandardInput.WriteLineAsync(JsonSerializer.Serialize(new { jsonrpc = "2.0", method, @params = parameters }));
diff --git a/tests/Helpdesk.Tests/Mcp/McpHttpRequestFramingTests.cs b/tests/Helpdesk.Tests/Mcp/McpHttpRequestFramingTests.cs
new file mode 100644
index 00000000..609db014
--- /dev/null
+++ b/tests/Helpdesk.Tests/Mcp/McpHttpRequestFramingTests.cs
@@ -0,0 +1,163 @@
+using System.Net;
+using System.Net.Http.Headers;
+using System.Text;
+using System.Text.Json;
+using Helpdesk.Mcp.Http;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Http.Features;
+using Xunit;
+
+namespace Helpdesk.Tests.Mcp;
+
+public sealed partial class McpHttpHostTests
+{
+ [Theory]
+ [InlineData("/mcp", false, 0)]
+ [InlineData("/mcp/", false, 0)]
+ [InlineData("/mcp/", true, 0)]
+ [InlineData("/mcp", false, 1_048_577)]
+ [InlineData("/mcp", true, 1_048_577)]
+ public async Task Duplicate_arguments_are_rejected_for_streamed_trailing_slash_and_large_requests(
+ string path, bool knownLength, int paddingBytes)
+ {
+ using var environment = new McpHostEnvironment(gateway: true);
+ var delegation = new GatewayDelegationProbe();
+ var outbound = new AgentClientBoundaryProbe();
+ using var factory = CreateFactory(environment.SigningKey, boundaryProbe: outbound, delegationProbe: delegation);
+ using var http = CreateClient(factory);
+ var raw = new string(' ', paddingBytes) +
+ "{\"jsonrpc\":\"2.0\",\"id\":44,\"method\":\"tools/call\",\"params\":{\"name\":\"helpdesk_notifications\",\"arguments\":{\"operation\":\"summary\"},\"arguments\":{\"operation\":\"mark_read\",\"confirm\":true}}}";
+ using var request = new HttpRequestMessage(HttpMethod.Post, path)
+ {
+ Content = knownLength
+ ? new StringContent(raw, Encoding.UTF8, "application/json")
+ : new UnknownLengthJsonContent(raw)
+ };
+ request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "rdk_local-a");
+ request.Headers.Accept.ParseAdd("application/json, text/event-stream");
+
+ using var response = await http.SendAsync(request);
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ using var result = ParseMcpResponse(await response.Content.ReadAsStringAsync());
+ Assert.Equal(44, result.RootElement.GetProperty("id").GetInt32());
+ var content = result.RootElement.GetProperty("result").GetProperty("structuredContent");
+ Assert.Equal("validation_failed", content.GetProperty("status").GetString());
+ Assert.Equal("validation_error", content.GetProperty("failure").GetProperty("code").GetString());
+ Assert.True(result.RootElement.GetProperty("result").GetProperty("isError").GetBoolean());
+ Assert.Equal(1, delegation.Requests);
+ Assert.Equal(0, outbound.ApiRequests);
+ Assert.Equal(0, outbound.TokenRequests);
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task Valid_large_initialize_request_remains_supported(bool knownLength)
+ {
+ using var environment = new McpHostEnvironment();
+ var outbound = new AgentClientBoundaryProbe();
+ using var factory = CreateFactory(environment.SigningKey, boundaryProbe: outbound);
+ using var http = CreateClient(factory);
+ var raw = new string(' ', 1_048_577) + JsonSerializer.Serialize(new
+ {
+ jsonrpc = "2.0", id = 45, method = "initialize", @params = InitializeParameters()
+ });
+ using var request = new HttpRequestMessage(HttpMethod.Post, "/mcp")
+ {
+ Content = knownLength
+ ? new StringContent(raw, Encoding.UTF8, "application/json")
+ : new UnknownLengthJsonContent(raw)
+ };
+ request.Headers.Authorization = new AuthenticationHeaderValue("Bearer",
+ CreateToken(environment.SigningKey, ["helpdesk.mcp"], ["ai-assistant"]));
+ request.Headers.Accept.ParseAdd("application/json, text/event-stream");
+
+ using var response = await http.SendAsync(request);
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ using var result = ParseMcpResponse(await response.Content.ReadAsStringAsync());
+ Assert.Equal(45, result.RootElement.GetProperty("id").GetInt32());
+ Assert.True(result.RootElement.GetProperty("result").TryGetProperty("serverInfo", out _));
+ Assert.Equal(0, outbound.ApiRequests);
+ Assert.Equal(0, outbound.TokenRequests);
+ }
+
+ private sealed class UnknownLengthJsonContent : HttpContent
+ {
+ private readonly byte[] _bytes;
+
+ public UnknownLengthJsonContent(string raw)
+ {
+ _bytes = Encoding.UTF8.GetBytes(raw);
+ Headers.ContentType = new MediaTypeHeaderValue("application/json");
+ }
+
+ protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) =>
+ stream.WriteAsync(_bytes).AsTask();
+
+ protected override bool TryComputeLength(out long length)
+ {
+ length = 0;
+ return false;
+ }
+ }
+}
+
+public sealed class McpHttpRequestBodyLimitTests
+{
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task Oversized_body_returns_413_without_invoking_the_next_handler(bool knownLength)
+ {
+ var context = new DefaultHttpContext();
+ context.Request.Path = "/mcp";
+ context.Request.Method = HttpMethods.Post;
+ context.Request.ContentType = "application/json";
+ context.Request.ContentLength = knownLength ? 257 : null;
+ context.Features.Set(new RequestBodyLimit { MaxRequestBodySize = 256 });
+ using var body = new MemoryStream(Encoding.UTF8.GetBytes(new string(' ', 257)));
+ context.Request.Body = body;
+ var invoked = false;
+ var middleware = new McpDuplicateArgumentsMiddleware(_ =>
+ {
+ invoked = true;
+ return Task.CompletedTask;
+ });
+
+ await middleware.InvokeAsync(context);
+
+ Assert.Equal(StatusCodes.Status413PayloadTooLarge, context.Response.StatusCode);
+ Assert.False(invoked);
+ }
+
+ [Fact]
+ public async Task Streamed_body_at_the_configured_limit_reaches_the_next_handler_unchanged()
+ {
+ var raw = new string(' ', 254) + "{}";
+ var context = new DefaultHttpContext();
+ context.Request.Path = "/mcp";
+ context.Request.Method = HttpMethods.Post;
+ context.Request.ContentType = "application/json";
+ context.Features.Set(new RequestBodyLimit { MaxRequestBodySize = 256 });
+ using var body = new MemoryStream(Encoding.UTF8.GetBytes(raw));
+ context.Request.Body = body;
+ string? received = null;
+ var middleware = new McpDuplicateArgumentsMiddleware(async current =>
+ {
+ using var reader = new StreamReader(current.Request.Body, leaveOpen: true);
+ received = await reader.ReadToEndAsync();
+ });
+
+ await middleware.InvokeAsync(context);
+
+ Assert.Equal(raw, received);
+ }
+
+ private sealed class RequestBodyLimit : IHttpMaxRequestBodySizeFeature
+ {
+ public bool IsReadOnly => false;
+ public long? MaxRequestBodySize { get; set; }
+ }
+}