-
Notifications
You must be signed in to change notification settings - Fork 680
Expand file tree
/
Copy pathHttpClientTransportAutoDetectTests.cs
More file actions
163 lines (139 loc) · 6.4 KB
/
HttpClientTransportAutoDetectTests.cs
File metadata and controls
163 lines (139 loc) · 6.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
using ModelContextProtocol.Client;
using ModelContextProtocol.Tests.Utils;
using System.Net;
namespace ModelContextProtocol.Tests.Transport;
public class HttpClientTransportAutoDetectTests(ITestOutputHelper testOutputHelper) : LoggedTest(testOutputHelper)
{
[Fact]
public async Task AutoDetectMode_UsesStreamableHttp_WhenServerSupportsIt()
{
var options = new HttpClientTransportOptions
{
Endpoint = new Uri("http://localhost"),
TransportMode = HttpTransportMode.AutoDetect,
Name = "AutoDetect test client"
};
using var mockHttpHandler = new MockHttpHandler();
using var httpClient = new HttpClient(mockHttpHandler);
await using var transport = new HttpClientTransport(options, httpClient, LoggerFactory);
// Simulate successful Streamable HTTP response for initialize
mockHttpHandler.RequestHandler = (request) =>
{
if (request.Method == HttpMethod.Post)
{
return Task.FromResult(new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK,
Content = new StringContent("{\"jsonrpc\":\"2.0\",\"id\":\"init-id\",\"result\":{\"protocolVersion\":\"2024-11-05\",\"capabilities\":{\"tools\":{}}}}"),
Headers =
{
{ "Content-Type", "application/json" },
{ "mcp-session-id", "test-session" }
}
});
}
// Shouldn't reach here for successful Streamable HTTP
throw new InvalidOperationException("Unexpected request");
};
await using var session = await transport.ConnectAsync(TestContext.Current.CancellationToken);
// The auto-detecting transport should be returned
Assert.NotNull(session);
}
[Fact]
public async Task AutoDetectMode_WhenBothTransportsFail_ThrowsInvalidOperationException()
{
// Regression test: when Streamable HTTP POST fails (e.g. 403) and the SSE GET
// fallback also fails (e.g. 405), ConnectAsync should wrap the error in an
// InvalidOperationException. Previously, CloseAsync() would re-throw the
// HttpRequestException from the faulted _receiveTask, preempting the wrapping.
var options = new HttpClientTransportOptions
{
Endpoint = new Uri("http://localhost"),
TransportMode = HttpTransportMode.AutoDetect,
Name = "AutoDetect test client"
};
using var mockHttpHandler = new MockHttpHandler();
using var httpClient = new HttpClient(mockHttpHandler);
await using var transport = new HttpClientTransport(options, httpClient, LoggerFactory);
mockHttpHandler.RequestHandler = (request) =>
{
if (request.Method == HttpMethod.Post)
{
// Streamable HTTP POST fails with 403 (auth error)
return Task.FromResult(new HttpResponseMessage
{
StatusCode = HttpStatusCode.Forbidden,
Content = new StringContent("Forbidden")
});
}
if (request.Method == HttpMethod.Get)
{
// SSE GET fallback fails with 405
return Task.FromResult(new HttpResponseMessage
{
StatusCode = HttpStatusCode.MethodNotAllowed,
Content = new StringContent("Method Not Allowed")
});
}
throw new InvalidOperationException($"Unexpected request: {request.Method}");
};
// ConnectAsync for AutoDetect mode just creates the transport without sending
// any HTTP request. The auto-detection is triggered lazily by the first
// SendMessageAsync call, which happens inside McpClient.CreateAsync when it
// sends the JSON-RPC "initialize" message.
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => McpClient.CreateAsync(transport, cancellationToken: TestContext.Current.CancellationToken));
Assert.Equal("Failed to connect transport", ex.Message);
Assert.IsType<HttpRequestException>(ex.InnerException);
}
[Fact]
public async Task AutoDetectMode_FallsBackToSse_WhenStreamableHttpFails()
{
var options = new HttpClientTransportOptions
{
Endpoint = new Uri("http://localhost"),
TransportMode = HttpTransportMode.AutoDetect,
Name = "AutoDetect test client"
};
using var mockHttpHandler = new MockHttpHandler();
using var httpClient = new HttpClient(mockHttpHandler);
await using var transport = new HttpClientTransport(options, httpClient, LoggerFactory);
var requestCount = 0;
mockHttpHandler.RequestHandler = (request) =>
{
requestCount++;
if (request.Method == HttpMethod.Post && requestCount == 1)
{
// First POST (Streamable HTTP) fails
return Task.FromResult(new HttpResponseMessage
{
StatusCode = HttpStatusCode.NotFound,
Content = new StringContent("Streamable HTTP not supported")
});
}
if (request.Method == HttpMethod.Get)
{
// SSE connection request
return Task.FromResult(new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK,
Content = new StringContent("event: endpoint\r\ndata: /sse-endpoint\r\n\r\n"),
Headers = { { "Content-Type", "text/event-stream" } }
});
}
if (request.Method == HttpMethod.Post && requestCount > 1)
{
// Subsequent POST to SSE endpoint succeeds
return Task.FromResult(new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK,
Content = new StringContent("accepted")
});
}
throw new InvalidOperationException($"Unexpected request: {request.Method}, count: {requestCount}");
};
await using var session = await transport.ConnectAsync(TestContext.Current.CancellationToken);
// The auto-detecting transport should be returned
Assert.NotNull(session);
}
}