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
19 changes: 19 additions & 0 deletions IMPLEMENTATION_PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
6 changes: 4 additions & 2 deletions src/Netclaw.Cli/Mcp/McpCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -920,14 +921,15 @@ internal static async Task<McpClient> 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
Expand Down
66 changes: 66 additions & 0 deletions src/Netclaw.Configuration.Tests/Http/McpHttpClientFactoryTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// -----------------------------------------------------------------------
// <copyright file="McpHttpClientFactoryTests.cs" company="Petabridge, LLC">
// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com>
// </copyright>
// -----------------------------------------------------------------------
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<CapturedHeaders> 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<CapturedHeaders>(capture.Headers);
}

private sealed record CapturedHeaders(string? ProtocolVersion, string? Authorization);

private sealed class CapturingHandler : HttpMessageHandler
{
public CapturedHeaders? Headers { get; private set; }

protected override Task<HttpResponseMessage> 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));
}
}
}
63 changes: 63 additions & 0 deletions src/Netclaw.Configuration/Http/McpHttpClientFactory.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
// -----------------------------------------------------------------------
// <copyright file="McpHttpClientFactory.cs" company="Petabridge, LLC">
// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com>
// </copyright>
// -----------------------------------------------------------------------
namespace Netclaw.Configuration.Http;

/// <summary>
/// Owns the process-wide HTTP client for MCP transports.
/// </summary>
internal static class McpHttpClientFactory
{
internal const string MethodHeaderName = "Mcp-Method";
internal const string ProtocolVersionHeaderName = "MCP-Protocol-Version";
internal const string InitializeMethod = "initialize";

/// <summary>
/// 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.
/// </summary>
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,
});
}

/// <summary>
/// Removes stale MCP protocol state from an HTTP initialize request.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
private sealed class ProtocolVersionHandler : DelegatingHandler
{
protected override Task<HttpResponseMessage> 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);
}
}
}
2 changes: 2 additions & 0 deletions src/Netclaw.Configuration/Netclaw.Configuration.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
</PropertyGroup>

<ItemGroup>
<InternalsVisibleTo Include="netclaw" />
<InternalsVisibleTo Include="netclawd" />
<InternalsVisibleTo Include="Netclaw.Configuration.Tests" />
<InternalsVisibleTo Include="Netclaw.Daemon.Tests" />
<InternalsVisibleTo Include="Netclaw.Cli.Tests" />
Expand Down
131 changes: 131 additions & 0 deletions src/Netclaw.Daemon.Tests/Mcp/McpProtocolVersionFallbackTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
// -----------------------------------------------------------------------
// <copyright file="McpProtocolVersionFallbackTests.cs" company="Petabridge, LLC">
// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com>
// </copyright>
// -----------------------------------------------------------------------
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<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
var body = JsonNode.Parse(
await request.Content!.ReadAsStringAsync(cancellationToken))!.AsObject();
var method = body["method"]!.GetValue<string>();

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<string>();

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"),
};
}
}
4 changes: 4 additions & 0 deletions src/Netclaw.Daemon/Mcp/McpClientManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -1822,6 +1823,9 @@ ValueTask<GetPromptResult> GetPromptAsync(

internal sealed class McpClientRuntime : IMcpClientRuntime
{
public IClientTransport CreateHttpTransport(HttpClientTransportOptions options)
=> new HttpClientTransport(options, McpHttpClientFactory.Shared);

public Task<McpClient> CreateAsync(
IClientTransport transport,
McpClientOptions options,
Expand Down
Loading