diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index ab37ef462..3ed09d068 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -110,6 +110,25 @@ the smallest repeatable manual script plus expected output. ## NOW +### Priority: Keep MCP HTTP Protocol Fallback Deterministic + +**PRD:** `docs/prd/PRD-006-mcp-tool-integration.md` +**Spec:** `openspec/specs/netclaw-mcp/spec.md` +**Surface area:** MCP HTTP transport, daemon connections, CLI probes +**Verification:** L1 plus the existing HTTP MCP smoke tests + +The MCP SDK can retain its discovery protocol version when probe cancellation +selects the initialize fallback. Netclaw must not send that stale version in an +initialize request. + +Done when: + +- [x] Daemon connections and CLI probes remove a retained protocol-version + header only from the initialize request. +- [x] Discovery and established-session requests keep their protocol-version + header. +- [x] Tests prove the header correction and preserve unrelated headers. + ### Priority: Preserve The Daemon Working Directory **PRD:** `docs/prd/PRD-001-netclaw-mvp.md` diff --git a/src/Netclaw.Cli/Mcp/McpCommand.cs b/src/Netclaw.Cli/Mcp/McpCommand.cs index 8e1d43ae5..cec11ded0 100644 --- a/src/Netclaw.Cli/Mcp/McpCommand.cs +++ b/src/Netclaw.Cli/Mcp/McpCommand.cs @@ -14,6 +14,7 @@ using Netclaw.Cli.Daemon; using Netclaw.Cli.Json; using Netclaw.Configuration; +using Netclaw.Configuration.Http; using Netclaw.Providers.OAuth; using Netclaw.Tools; @@ -920,14 +921,15 @@ internal static async Task CreateOneOffClientAsync(McpServerName serv if (!headers.ContainsKey(NetclawUserAgent.ComponentHeader)) headers[NetclawUserAgent.ComponentHeader] = "mcp-probe"; - transport = new HttpClientTransport(new HttpClientTransportOptions + var options = new HttpClientTransportOptions { Endpoint = new Uri(entry.Url!), Name = serverName.Value, AdditionalHeaders = headers, TransportMode = entry.Transport is "sse" ? HttpTransportMode.Sse : HttpTransportMode.AutoDetect, - }); + }; + transport = new HttpClientTransport(options, McpHttpClientFactory.Shared); } return await McpClient.CreateAsync(transport, new McpClientOptions diff --git a/src/Netclaw.Configuration.Tests/Http/McpHttpClientFactoryTests.cs b/src/Netclaw.Configuration.Tests/Http/McpHttpClientFactoryTests.cs new file mode 100644 index 000000000..18ec82bba --- /dev/null +++ b/src/Netclaw.Configuration.Tests/Http/McpHttpClientFactoryTests.cs @@ -0,0 +1,66 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using System.Net; +using Netclaw.Configuration.Http; +using Xunit; + +namespace Netclaw.Configuration.Tests.Http; + +public sealed class McpHttpClientFactoryTests +{ + [Fact] + public async Task Initialize_removes_stale_protocol_version() + { + var captured = await SendAsync("initialize"); + + Assert.Null(captured.ProtocolVersion); + Assert.Equal("Bearer test-token", captured.Authorization); + } + + [Theory] + [InlineData("server/discover")] + [InlineData("tools/list")] + public async Task Other_methods_preserve_protocol_version(string method) + { + var captured = await SendAsync(method); + + Assert.Equal("2026-07-28", captured.ProtocolVersion); + } + + private static async Task SendAsync(string method) + { + var capture = new CapturingHandler(); + using var client = McpHttpClientFactory.Create(capture); + using var request = new HttpRequestMessage(HttpMethod.Post, "https://example.invalid/mcp"); + request.Headers.Add(McpHttpClientFactory.MethodHeaderName, method); + request.Headers.Add(McpHttpClientFactory.ProtocolVersionHeaderName, "2026-07-28"); + request.Headers.Authorization = new("Bearer", "test-token"); + + using var response = await client.SendAsync(request, TestContext.Current.CancellationToken); + return Assert.IsType(capture.Headers); + } + + private sealed record CapturedHeaders(string? ProtocolVersion, string? Authorization); + + private sealed class CapturingHandler : HttpMessageHandler + { + public CapturedHeaders? Headers { get; private set; } + + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + Headers = new( + request.Headers.TryGetValues( + McpHttpClientFactory.ProtocolVersionHeaderName, + out var versions) + ? Assert.Single(versions) + : null, + request.Headers.Authorization?.ToString()); + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.Accepted)); + } + } +} diff --git a/src/Netclaw.Configuration/Http/McpHttpClientFactory.cs b/src/Netclaw.Configuration/Http/McpHttpClientFactory.cs new file mode 100644 index 000000000..45b0c58cb --- /dev/null +++ b/src/Netclaw.Configuration/Http/McpHttpClientFactory.cs @@ -0,0 +1,63 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +namespace Netclaw.Configuration.Http; + +/// +/// Owns the process-wide HTTP client for MCP transports. +/// +internal static class McpHttpClientFactory +{ + internal const string MethodHeaderName = "Mcp-Method"; + internal const string ProtocolVersionHeaderName = "MCP-Protocol-Version"; + internal const string InitializeMethod = "initialize"; + + /// + /// Gets the client shared by every MCP HTTP transport in this process. + /// The process owns its lifetime so one short-lived transport cannot close + /// the connection pool while another transport is using it. + /// + public static HttpClient Shared { get; } = Create(new SocketsHttpHandler + { + // MCP profiles share this connection pool. Ambient cookies could cross + // profile boundaries on the same host; authentication stays explicit. + UseCookies = false, + }); + + internal static HttpClient Create(HttpMessageHandler innerHandler) + { + ArgumentNullException.ThrowIfNull(innerHandler); + return new HttpClient(new ProtocolVersionHandler + { + InnerHandler = innerHandler, + }); + } + + /// + /// Removes stale MCP protocol state from an HTTP initialize request. + /// + /// + /// MCP SDK 2.x can retain the protocol version from a completed discovery + /// request when its discovery wait is cancelled. The SDK can then send an + /// initialize body for the legacy protocol with the retained modern + /// protocol version in the HTTP header. An initialize request starts + /// negotiation, so it must not carry state from the discovery attempt. + /// + private sealed class ProtocolVersionHandler : DelegatingHandler + { + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + if (request.Headers.TryGetValues(MethodHeaderName, out var methods) + && methods.Contains(InitializeMethod, StringComparer.Ordinal)) + { + request.Headers.Remove(ProtocolVersionHeaderName); + } + + return base.SendAsync(request, cancellationToken); + } + } +} diff --git a/src/Netclaw.Configuration/Netclaw.Configuration.csproj b/src/Netclaw.Configuration/Netclaw.Configuration.csproj index 6202261a9..6d4ffe522 100644 --- a/src/Netclaw.Configuration/Netclaw.Configuration.csproj +++ b/src/Netclaw.Configuration/Netclaw.Configuration.csproj @@ -7,6 +7,8 @@ + + diff --git a/src/Netclaw.Daemon.Tests/Mcp/McpProtocolVersionFallbackTests.cs b/src/Netclaw.Daemon.Tests/Mcp/McpProtocolVersionFallbackTests.cs new file mode 100644 index 000000000..c778ab5b5 --- /dev/null +++ b/src/Netclaw.Daemon.Tests/Mcp/McpProtocolVersionFallbackTests.cs @@ -0,0 +1,131 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using System.Net; +using System.Text; +using System.Text.Json.Nodes; +using ModelContextProtocol.Client; +using Netclaw.Configuration.Http; +using Xunit; + +namespace Netclaw.Daemon.Tests.Mcp; + +public sealed class McpProtocolVersionFallbackTests +{ + [Fact] + public async Task Initialize_fallback_does_not_reuse_discovery_protocol_header() + { + var server = new ControlledFallbackHandler(); + using var httpClient = McpHttpClientFactory.Create(server); + await using var transport = new HttpClientTransport( + new HttpClientTransportOptions + { + Endpoint = new Uri("https://example.invalid/mcp"), + Name = "controlled-fallback", + TransportMode = HttpTransportMode.StreamableHttp, + }, + httpClient); + + await using var client = await McpClient.CreateAsync( + transport, + new McpClientOptions(), + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal("2026-07-28", server.DiscoverProtocolVersion); + Assert.Null(server.InitializeProtocolVersion); + Assert.Equal("2025-11-25", server.InitializeBodyVersion); + Assert.Equal("2025-11-25", client.NegotiatedProtocolVersion); + } + + private sealed class ControlledFallbackHandler : HttpMessageHandler + { + public string? DiscoverProtocolVersion { get; private set; } + + public string? InitializeProtocolVersion { get; private set; } + + public string? InitializeBodyVersion { get; private set; } + + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + var body = JsonNode.Parse( + await request.Content!.ReadAsStringAsync(cancellationToken))!.AsObject(); + var method = body["method"]!.GetValue(); + + return method switch + { + "server/discover" => Discover(request, body), + "initialize" => Initialize(request, body), + "notifications/initialized" => new HttpResponseMessage(HttpStatusCode.Accepted), + _ => throw new InvalidOperationException($"Unexpected MCP method '{method}'."), + }; + } + + private HttpResponseMessage Discover(HttpRequestMessage request, JsonObject body) + { + DiscoverProtocolVersion = GetProtocolVersion(request); + return JsonResponse(new JsonObject + { + ["jsonrpc"] = "2.0", + ["id"] = body["id"]!.DeepClone(), + ["result"] = new JsonObject + { + ["supportedVersions"] = new JsonArray("2025-11-25"), + ["capabilities"] = new JsonObject(), + }, + }); + } + + private HttpResponseMessage Initialize(HttpRequestMessage request, JsonObject body) + { + InitializeProtocolVersion = GetProtocolVersion(request); + InitializeBodyVersion = body["params"]!["protocolVersion"]!.GetValue(); + + if (InitializeProtocolVersion is not null) + { + return JsonResponse(new JsonObject + { + ["jsonrpc"] = "2.0", + ["id"] = body["id"]!.DeepClone(), + ["error"] = new JsonObject + { + ["code"] = -32020, + ["message"] = "Protocol header and initialize body do not match.", + }, + }, HttpStatusCode.BadRequest); + } + + return JsonResponse(new JsonObject + { + ["jsonrpc"] = "2.0", + ["id"] = body["id"]!.DeepClone(), + ["result"] = new JsonObject + { + ["protocolVersion"] = "2025-11-25", + ["capabilities"] = new JsonObject(), + ["serverInfo"] = new JsonObject + { + ["name"] = "controlled-fallback", + ["version"] = "1.0.0", + }, + }, + }); + } + + private static string? GetProtocolVersion(HttpRequestMessage request) + => request.Headers.TryGetValues(McpHttpClientFactory.ProtocolVersionHeaderName, out var values) + ? Assert.Single(values) + : null; + + private static HttpResponseMessage JsonResponse( + JsonObject body, + HttpStatusCode statusCode = HttpStatusCode.OK) + => new(statusCode) + { + Content = new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json"), + }; + } +} diff --git a/src/Netclaw.Daemon/Mcp/McpClientManager.cs b/src/Netclaw.Daemon/Mcp/McpClientManager.cs index f1efeb135..4f83d8dbe 100644 --- a/src/Netclaw.Daemon/Mcp/McpClientManager.cs +++ b/src/Netclaw.Daemon/Mcp/McpClientManager.cs @@ -22,6 +22,7 @@ using Netclaw.Actors.Skills; using Netclaw.Actors.Tools; using Netclaw.Configuration; +using Netclaw.Configuration.Http; using Netclaw.Security; using Netclaw.Tools; @@ -1822,6 +1823,9 @@ ValueTask GetPromptAsync( internal sealed class McpClientRuntime : IMcpClientRuntime { + public IClientTransport CreateHttpTransport(HttpClientTransportOptions options) + => new HttpClientTransport(options, McpHttpClientFactory.Shared); + public Task CreateAsync( IClientTransport transport, McpClientOptions options,