Restructure IMcpEndpoint, IMcpClient, and IMcpServer (#723)
* Remove `McpEndpoint` & `IMcpClient` * Revise naming, add abstract classes * Fix missing renames * Move extension methods to instance methods * Add back old API, but obsoleted * Add back obsoleted factory classes * Move deprecation attributes * `SseClientTransport` -> `HttpClientTransport` * Update src/ModelContextProtocol.Core/Server/AugmentedServiceProvider.cs Co-authored-by: Stephen Halter <halter73@gmail.com> * PR feedback * PR feedback * Add back dispose lock * Fix docs projects * PR feedback * Undo `docs/*.md` changes --------- Co-authored-by: Stephen Halter <halter73@gmail.com>
This commit is contained in:
parent
fe2ce2bc60
commit
38b4a26998
3 changed files with 5169 additions and 3786 deletions
|
|
@ -0,0 +1,109 @@
|
|||
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_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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,176 @@
|
|||
using ModelContextProtocol.Client;
|
||||
using ModelContextProtocol.Protocol;
|
||||
using ModelContextProtocol.Tests.Utils;
|
||||
using System.Net;
|
||||
|
||||
namespace ModelContextProtocol.Tests.Transport;
|
||||
|
||||
public class HttpClientTransportTests : LoggedTest
|
||||
{
|
||||
private readonly HttpClientTransportOptions _transportOptions;
|
||||
|
||||
public HttpClientTransportTests(ITestOutputHelper testOutputHelper)
|
||||
: base(testOutputHelper)
|
||||
{
|
||||
_transportOptions = new HttpClientTransportOptions
|
||||
{
|
||||
Endpoint = new Uri("http://localhost:8080"),
|
||||
ConnectionTimeout = TimeSpan.FromSeconds(2),
|
||||
Name = "Test Server",
|
||||
TransportMode = HttpTransportMode.Sse,
|
||||
AdditionalHeaders = new Dictionary<string, string>
|
||||
{
|
||||
["test"] = "header"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_Throws_For_Null_Options()
|
||||
{
|
||||
var exception = Assert.Throws<ArgumentNullException>(() => new HttpClientTransport(null!, LoggerFactory));
|
||||
Assert.Equal("transportOptions", exception.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_Throws_For_Null_HttpClient()
|
||||
{
|
||||
var exception = Assert.Throws<ArgumentNullException>(() => new HttpClientTransport(_transportOptions, httpClient: null!, LoggerFactory));
|
||||
Assert.Equal("httpClient", exception.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConnectAsync_Should_Connect_Successfully()
|
||||
{
|
||||
using var mockHttpHandler = new MockHttpHandler();
|
||||
using var httpClient = new HttpClient(mockHttpHandler);
|
||||
await using var transport = new HttpClientTransport(_transportOptions, httpClient, LoggerFactory);
|
||||
|
||||
bool firstCall = true;
|
||||
|
||||
mockHttpHandler.RequestHandler = (request) =>
|
||||
{
|
||||
firstCall = false;
|
||||
return Task.FromResult(new HttpResponseMessage
|
||||
{
|
||||
StatusCode = HttpStatusCode.OK,
|
||||
Content = new StringContent("event: endpoint\r\ndata: http://localhost\r\n\r\n")
|
||||
});
|
||||
};
|
||||
|
||||
await using var session = await transport.ConnectAsync(TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(session);
|
||||
Assert.False(firstCall);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConnectAsync_Throws_Exception_On_Failure()
|
||||
{
|
||||
using var mockHttpHandler = new MockHttpHandler();
|
||||
using var httpClient = new HttpClient(mockHttpHandler);
|
||||
await using var transport = new HttpClientTransport(_transportOptions, httpClient, LoggerFactory);
|
||||
|
||||
var retries = 0;
|
||||
mockHttpHandler.RequestHandler = (request) =>
|
||||
{
|
||||
retries++;
|
||||
throw new Exception("Test exception");
|
||||
};
|
||||
|
||||
var exception = await Assert.ThrowsAsync<Exception>(() => transport.ConnectAsync(TestContext.Current.CancellationToken));
|
||||
Assert.Equal("Test exception", exception.Message);
|
||||
Assert.Equal(1, retries);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendMessageAsync_Handles_Accepted_Response()
|
||||
{
|
||||
using var mockHttpHandler = new MockHttpHandler();
|
||||
using var httpClient = new HttpClient(mockHttpHandler);
|
||||
await using var transport = new HttpClientTransport(_transportOptions, httpClient, LoggerFactory);
|
||||
|
||||
var firstCall = true;
|
||||
mockHttpHandler.RequestHandler = (request) =>
|
||||
{
|
||||
if (request.Method == HttpMethod.Post && request.RequestUri?.AbsoluteUri == "http://localhost:8080/sseendpoint")
|
||||
{
|
||||
return Task.FromResult(new HttpResponseMessage
|
||||
{
|
||||
StatusCode = HttpStatusCode.OK,
|
||||
Content = new StringContent("accepted")
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!firstCall)
|
||||
throw new IOException("Abort");
|
||||
else
|
||||
firstCall = false;
|
||||
|
||||
return Task.FromResult(new HttpResponseMessage
|
||||
{
|
||||
StatusCode = HttpStatusCode.OK,
|
||||
Content = new StringContent("event: endpoint\r\ndata: /sseendpoint\r\n\r\n")
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
await using var session = await transport.ConnectAsync(TestContext.Current.CancellationToken);
|
||||
await session.SendMessageAsync(new JsonRpcRequest { Method = RequestMethods.Initialize, Id = new RequestId(44) }, CancellationToken.None);
|
||||
Assert.True(true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReceiveMessagesAsync_Handles_Messages()
|
||||
{
|
||||
using var mockHttpHandler = new MockHttpHandler();
|
||||
using var httpClient = new HttpClient(mockHttpHandler);
|
||||
await using var transport = new HttpClientTransport(_transportOptions, httpClient, LoggerFactory);
|
||||
|
||||
var callIndex = 0;
|
||||
mockHttpHandler.RequestHandler = (request) =>
|
||||
{
|
||||
callIndex++;
|
||||
|
||||
if (callIndex == 1)
|
||||
{
|
||||
return Task.FromResult(new HttpResponseMessage
|
||||
{
|
||||
StatusCode = HttpStatusCode.OK,
|
||||
Content = new StringContent("event: endpoint\r\ndata: /sseendpoint\r\n\r\nevent: message\r\ndata: {\"jsonrpc\":\"2.0\", \"id\": \"44\", \"method\": \"test\", \"params\": null}\r\n\r\n")
|
||||
});
|
||||
}
|
||||
|
||||
throw new IOException("Abort");
|
||||
};
|
||||
|
||||
await using var session = await transport.ConnectAsync(TestContext.Current.CancellationToken);
|
||||
Assert.True(session.MessageReader.TryRead(out var message));
|
||||
Assert.NotNull(message);
|
||||
Assert.IsType<JsonRpcRequest>(message);
|
||||
Assert.Equal("44", ((JsonRpcRequest)message).Id.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DisposeAsync_Should_Dispose_Resources()
|
||||
{
|
||||
using var mockHttpHandler = new MockHttpHandler();
|
||||
using var httpClient = new HttpClient(mockHttpHandler);
|
||||
mockHttpHandler.RequestHandler = request =>
|
||||
{
|
||||
return Task.FromResult(new HttpResponseMessage
|
||||
{
|
||||
StatusCode = HttpStatusCode.OK,
|
||||
Content = new StringContent("event: endpoint\r\ndata: http://localhost\r\n\r\n")
|
||||
});
|
||||
};
|
||||
|
||||
await using var transport = new HttpClientTransport(_transportOptions, httpClient, LoggerFactory);
|
||||
await using var session = await transport.ConnectAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
await session.DisposeAsync();
|
||||
|
||||
var transportBase = Assert.IsAssignableFrom<TransportBase>(session);
|
||||
Assert.False(transportBase.IsConnected);
|
||||
}
|
||||
}
|
||||
|
|
@ -8,7 +8,7 @@ namespace ModelContextProtocol.Tests.Transport;
|
|||
public class StdioClientTransportTests(ITestOutputHelper testOutputHelper) : LoggedTest(testOutputHelper)
|
||||
{
|
||||
public static bool IsStdErrCallbackSupported => !PlatformDetection.IsMonoRuntime;
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_ValidProcessInvalidServer_Throws()
|
||||
{
|
||||
|
|
@ -18,13 +18,13 @@ public class StdioClientTransportTests(ITestOutputHelper testOutputHelper) : Log
|
|||
new(new() { Command = "cmd", Arguments = ["/C", $"echo \"{id}\" >&2"] }, LoggerFactory) :
|
||||
new(new() { Command = "ls", Arguments = [id] }, LoggerFactory);
|
||||
|
||||
IOException e = await Assert.ThrowsAsync<IOException>(() => McpClientFactory.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken));
|
||||
IOException e = await Assert.ThrowsAsync<IOException>(() => McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken));
|
||||
if (!RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
|
||||
{
|
||||
Assert.Contains(id, e.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
[Fact(Skip = "Platform not supported by this test.", SkipUnless = nameof(IsStdErrCallbackSupported))]
|
||||
public async Task CreateAsync_ValidProcessInvalidServer_StdErrCallbackInvoked()
|
||||
{
|
||||
|
|
@ -46,7 +46,7 @@ public class StdioClientTransportTests(ITestOutputHelper testOutputHelper) : Log
|
|||
new(new() { Command = "cmd", Arguments = ["/C", $"echo \"{id}\" >&2"], StandardErrorLines = stdErrCallback }, LoggerFactory) :
|
||||
new(new() { Command = "ls", Arguments = [id], StandardErrorLines = stdErrCallback }, LoggerFactory);
|
||||
|
||||
await Assert.ThrowsAsync<IOException>(() => McpClientFactory.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken));
|
||||
await Assert.ThrowsAsync<IOException>(() => McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken));
|
||||
|
||||
Assert.InRange(count, 1, int.MaxValue);
|
||||
Assert.Contains(id, sb.ToString());
|
||||
|
|
|
|||
Loading…
Reference in a new issue