Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/mcp-http/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
80 changes: 80 additions & 0 deletions src/Helpdesk.Mcp.Core/Tools/McpDuplicateArgumentsGuard.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
using System.Text.Json;

namespace Helpdesk.Mcp.Tools;

/// <summary>
/// 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.
/// </summary>
public static class McpDuplicateArgumentsGuard
{
public static bool TryNormalize(ReadOnlyMemory<byte> 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<byte[]?> 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;
}
}
62 changes: 62 additions & 0 deletions src/Helpdesk.Mcp.Core/Tools/McpDuplicateArgumentsStream.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
using System.Text;

namespace Helpdesk.Mcp.Tools;

/// <summary>Filters newline-delimited stdio frames before SDK parameter binding.</summary>
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<byte> 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<int> ReadAsync(Memory<byte> 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);
}
}
71 changes: 71 additions & 0 deletions src/Helpdesk.Mcp.Http/McpDuplicateArgumentsMiddleware.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
using Helpdesk.Mcp.Tools;
using Microsoft.AspNetCore.Http.Features;

namespace Helpdesk.Mcp.Http;

/// <summary>Validates duplicate argument envelopes before the MCP SDK binds HTTP requests.</summary>
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<IHttpMaxRequestBodySizeFeature>()?.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);
}
}
1 change: 1 addition & 0 deletions src/Helpdesk.Mcp.Http/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@
.WithTools(HelpdeskToolDefinitions.Create())
.WithResources(resourceTarget)
.WithPromptsFromAssembly(typeof(HelpdeskPrompts).Assembly)
.WithRequestFilters(filters => filters.AddCallToolFilter(next => (context, cancellationToken) =>

Check warning on line 177 in src/Helpdesk.Mcp.Http/Program.cs

View workflow job for this annotation

GitHub Actions / .NET validation

'McpRequestFilterBuilderExtensions.AddSetLoggingLevelFilter(IMcpRequestFilterBuilder, McpRequestFilter<SetLevelRequestParams, EmptyResult>)' is obsolete: 'The Logging feature is deprecated as of specification version 2026-07-28 and may be removed in a future version. See SEP-2577 for more information.' (https://github.com/modelcontextprotocol/csharp-sdk/blob/main/docs/list-of-diagnostics.md#obsolete-apis)

Check warning on line 177 in src/Helpdesk.Mcp.Http/Program.cs

View workflow job for this annotation

GitHub Actions / Incident receiver HTTP and provider acceptance

'McpRequestFilterBuilderExtensions.AddSetLoggingLevelFilter(IMcpRequestFilterBuilder, McpRequestFilter<SetLevelRequestParams, EmptyResult>)' is obsolete: 'The Logging feature is deprecated as of specification version 2026-07-28 and may be removed in a future version. See SEP-2577 for more information.' (https://github.com/modelcontextprotocol/csharp-sdk/blob/main/docs/list-of-diagnostics.md#obsolete-apis)
{
var services = context.Services ?? throw new InvalidOperationException("MCP request context does not have a service provider.");
return services.GetRequiredService<McpToolInvocationAudit>().InvokeAsync(
Expand Down Expand Up @@ -222,6 +222,7 @@
app.UseRateLimiter();
app.UseAuthentication();
app.UseAuthorization();
app.UseMiddleware<McpDuplicateArgumentsMiddleware>();
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)
Expand Down
2 changes: 1 addition & 1 deletion src/Helpdesk.Mcp/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ public static async Task<int> 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);
Expand Down
63 changes: 63 additions & 0 deletions tests/Helpdesk.Tests/Mcp/McpHttpBeta9RegressionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
9 changes: 9 additions & 0 deletions tests/Helpdesk.Tests/Mcp/McpHttpHostTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1923,6 +1923,15 @@ public async Task<JsonDocument> CallAsync(int id, string method, object paramete
return JsonDocument.Parse(line!);
}

public async Task<JsonDocument> 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 }));
Expand Down
Loading
Loading