Reorganize folders, samples, minor bug fix (#14)

Reorganize folders
Samples
Casing fixes
This commit is contained in:
Peder Holdgaard Pedersen 2025-01-18 12:34:13 +01:00 • committed by GitHub
commit 3e35efe79c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 501 additions and 20 deletions

View file

@ -1 +0,0 @@
global using Xunit;

View file

@ -1,49 +0,0 @@
using McpDotNet.Client;
using McpDotNet.Configuration;
using Microsoft.Extensions.Logging;
namespace McpDotNet.Tests;
public class IntegrationTestFixture : IDisposable
{
public ILoggerFactory LoggerFactory { get; }
public McpClientFactory Factory { get; }
public McpClientOptions DefaultOptions { get; }
public McpServerConfig DefaultConfig { get; }
public IntegrationTestFixture()
{
LoggerFactory = Microsoft.Extensions.Logging.LoggerFactory.Create(builder =>
builder.AddConsole()
.SetMinimumLevel(LogLevel.Debug));
DefaultOptions = new()
{
ClientInfo = new() { Name = "IntegrationTestClient", Version = "1.0.0" }
};
DefaultConfig = new McpServerConfig
{
Id = "everything",
Name = "Everything",
TransportType = "stdio",
TransportOptions = new Dictionary<string, string>
{
["command"] = "npx",
["arguments"] = "-y @modelcontextprotocol/server-everything",
}
};
// Inject the mock transport into the factory
Factory = new McpClientFactory(
[DefaultConfig],
DefaultOptions,
LoggerFactory
);
}
public void Dispose()
{
LoggerFactory?.Dispose();
}
}

View file

@ -1,344 +0,0 @@
using McpDotNet.Client;
using McpDotNet.Configuration;
using McpDotNet.Protocol.Messages;
using McpDotNet.Protocol.Types;
namespace McpDotNet.Tests;
public class IntegrationTests : IClassFixture<IntegrationTestFixture>
{
private readonly IntegrationTestFixture _fixture;
public IntegrationTests(IntegrationTestFixture fixture)
{
_fixture = fixture;
}
[Fact]
public async Task ConnectAndPing_Stdio_EverythingServer()
{
// Arrange
// Act
var client = await _fixture.Factory.GetClientAsync("everything");
await client.PingAsync(CancellationToken.None);
// Assert
Assert.NotNull(client);
}
[Fact]
public async Task ListTools_Stdio_EverythingServer()
{
// arrange
// act
var client = await _fixture.Factory.GetClientAsync("everything");
var tools = await client.ListToolsAsync();
// assert
Assert.NotNull(tools);
Assert.NotEmpty(tools.Tools);
// We could add more specific assertions about expected tools
}
[Fact]
public async Task CallTool_Stdio_EchoServer()
{
// arrange
// act
var client = await _fixture.Factory.GetClientAsync("everything");
var result = await client.CallToolAsync(
"echo",
new Dictionary<string, object>
{
["message"] = "Hello MCP!"
},
CancellationToken.None
);
// assert
Assert.NotNull(result);
Assert.False(result.IsError);
var textContent = Assert.Single(result.Content, c => c.Type == "text");
Assert.Equal("Echo: Hello MCP!", textContent.Text);
}
[Fact]
public async Task ListPrompts_Stdio_EverythingServer()
{
// arrange
// act
var client = await _fixture.Factory.GetClientAsync("everything");
var prompts = await client.ListPromptsAsync();
// assert
Assert.NotNull(prompts);
Assert.NotEmpty(prompts.Prompts);
// We could add specific assertions for the known prompts
Assert.Contains(prompts.Prompts, p => p.Name == "simple_prompt");
Assert.Contains(prompts.Prompts, p => p.Name == "complex_prompt");
}
[Fact]
public async Task GetPrompt_Stdio_SimplePrompt()
{
// arrange
// act
var client = await _fixture.Factory.GetClientAsync("everything");
var result = await client.GetPromptAsync("simple_prompt", null, CancellationToken.None);
// assert
Assert.NotNull(result);
Assert.NotEmpty(result.Messages);
}
[Fact]
public async Task GetPrompt_Stdio_ComplexPrompt()
{
// arrange
// act
var client = await _fixture.Factory.GetClientAsync("everything");
var arguments = new Dictionary<string, object>
{
{ "temperature", "0.7" },
{ "style", "formal" }
};
var result = await client.GetPromptAsync("complex_prompt", arguments, CancellationToken.None);
// assert
Assert.NotNull(result);
Assert.NotEmpty(result.Messages);
}
[Fact]
public async Task GetPrompt_NonExistent_ThrowsException()
{
// arrange
// act
var client = await _fixture.Factory.GetClientAsync("everything");
await Assert.ThrowsAsync<McpClientException>(() =>
client.GetPromptAsync("non_existent_prompt", null, CancellationToken.None));
}
[Fact]
public async Task ListResources_Stdio_EverythingServer()
{
// arrange
// act
var client = await _fixture.Factory.GetClientAsync("everything");
List<Resource> allResources = new();
string? cursor = null;
do
{
var resources = await client.ListResourcesAsync(cursor, CancellationToken.None);
allResources.AddRange(resources.Resources);
cursor = resources.NextCursor;
}
while (cursor != null);
// The everything server provides 100 test resources
Assert.Equal(100, allResources.Count);
}
[Fact]
public async Task ReadResource_Stdio_TextResource()
{
// arrange
// act
var client = await _fixture.Factory.GetClientAsync("everything");
// Odd numbered resources are text in the everything server (despite the docs saying otherwise)
// 1 is index 0, which is "even" in the 0-based index
var result = await client.ReadResourceAsync("test://static/resource/1", CancellationToken.None);
Assert.NotNull(result);
Assert.Single(result.Contents);
Assert.NotNull(result.Contents[0].Text);
}
[Fact]
public async Task ReadResource_Stdio_BinaryResource()
{
// arrange
// act
var client = await _fixture.Factory.GetClientAsync("everything");
// Even numbered resources are binary in the everything server (despite the docs saying otherwise)
// 2 is index 1, which is "odd" in the 0-based index
var result = await client.ReadResourceAsync("test://static/resource/2", CancellationToken.None);
Assert.NotNull(result);
Assert.Single(result.Contents);
Assert.NotNull(result.Contents[0].Blob);
}
[Fact]
public async Task Sampling_Stdio_EverythingServer()
{
// arrange
var config = new McpServerConfig
{
Id = "everything",
Name = "Everything",
TransportType = "stdio",
TransportOptions = new Dictionary<string, string>
{
["command"] = "npx",
["arguments"] = "-y @modelcontextprotocol/server-everything",
}
};
var options = new McpClientOptions
{
ClientInfo = new() { Name = "IntegrationTestClient", Version = "1.0.0" },
Capabilities = new ClientCapabilities
{
Sampling = new()
}
};
var factory = new McpClientFactory(
[config],
options,
_fixture.LoggerFactory
);
var client = await factory.GetClientAsync("everything");
// Set up the sampling handler
int samplingHandlerCalls = 0;
#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously
client.SamplingHandler = async (_,_) =>
{
samplingHandlerCalls++;
return new CreateMessageResult
{
Model = "test-model",
Role = "assistant",
Content = new Content
{
Type = "text",
Text = "Test response"
}
};
};
#pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously
// Call the server's sampleLLM tool which should trigger our sampling handler
var result = await client.CallToolAsync(
"sampleLLM",
new Dictionary<string, object>
{
["prompt"] = "Test prompt",
["maxTokens"] = 100
}
);
// assert
Assert.NotNull(result);
var textContent = Assert.Single(result.Content);
Assert.Equal("text", textContent.Type);
Assert.False(string.IsNullOrEmpty(textContent.Text));
}
[Fact]
public async Task Roots_Stdio_EverythingServer()
{
// arrange
var config = new McpServerConfig
{
Id = "everything",
Name = "everything",
TransportType = "stdio",
TransportOptions = new Dictionary<string, string>
{
["command"] = "npx",
["arguments"] = "-y @modelcontextprotocol/server-everything"
}
};
var options = new McpClientOptions
{
ClientInfo = new() { Name = "IntegrationTestClient", Version = "1.0.0" },
Capabilities = new ClientCapabilities
{
Roots = new()
}
};
var rootsHandlerCalls = 0;
var testRoots = new List<Root>
{
new() { Uri = "file:///test/root1", Name = "Test Root 1" },
new() { Uri = "file:///test/root2", Name = "Test Root 2" }
};
var factory = new McpClientFactory(
[config],
options,
_fixture.LoggerFactory
);
var client = await factory.GetClientAsync("everything");
// Set up the roots handler
#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously
client.RootsHandler = async (request, ct) =>
{
rootsHandlerCalls++;
return new ListRootsResult
{
Roots = testRoots
};
};
#pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously
// Connect
await client.ConnectAsync(CancellationToken.None);
// assert
// nothing to assert, no servers implement roots, so we if no exception is thrown, it's a success
Assert.True(true);
}
[Fact]
public async Task Notifications_Stdio_EverythingServer()
{
// arrange
var config = new McpServerConfig
{
Id = "everything",
Name = "everything",
TransportType = "stdio",
TransportOptions = new Dictionary<string, string>
{
["command"] = "npx",
["arguments"] = "-y @modelcontextprotocol/server-everything"
}
};
var options = new McpClientOptions
{
ClientInfo = new() { Name = "IntegrationTestClient", Version = "1.0.0" }
};
var factory = new McpClientFactory([config], options, _fixture.LoggerFactory);
var client = await factory.GetClientAsync("everything");
await client.ConnectAsync();
// Verify we can send notifications without errors
await client.SendNotificationAsync(NotificationMethods.RootsUpdatedNotification);
await client.SendNotificationAsync("test/notification", new { test = true });
// assert
// no response to check, if no exception is thrown, it's a success
Assert.True(true);
}
}

View file

@ -1,366 +0,0 @@
using McpDotNet.Client;
using McpDotNet.Configuration;
using McpDotNet.Protocol.Messages;
using McpDotNet.Protocol.Transport;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using System.Threading.Channels;
namespace McpDotNet.Tests.Client;
public class McpClientFactoryTests
{
private readonly McpClientOptions _defaultOptions = new()
{
ClientInfo = new() { Name = "TestClient", Version = "1.0.0" }
};
[Fact]
public async Task GetClientAsync_WithValidStdioConfig_CreatesNewClient()
{
// Arrange
var config = new McpServerConfig
{
Id = "test-server",
Name = "Test Server",
TransportType = "stdio",
Location = "/path/to/server",
TransportOptions = new Dictionary<string, string>
{
["arguments"] = "--test arg",
["workingDirectory"] = "/working/dir"
}
};
// Create a mock transport
var mockTransport = new Mock<IMcpTransport>();
mockTransport.Setup(t => t.ConnectAsync(It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
mockTransport.Setup(t => t.IsConnected).Returns(true);
mockTransport.Setup(t => t.MessageReader).Returns(Mock.Of<ChannelReader<IJsonRpcMessage>>());
// Create a mock client
var mockClient = new Mock<IMcpClient>();
mockClient.Setup(c => c.ConnectAsync(It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
mockClient.Setup(c => c.IsInitialized).Returns(true);
// Inject the mock transport into the factory
var factory = new McpClientFactory(
[config],
_defaultOptions,
NullLoggerFactory.Instance,
transportFactoryMethod: _ => mockTransport.Object,
clientFactoryMethod: (_, _, _) => mockClient.Object
);
// Act
var client = await factory.GetClientAsync("test-server");
// Assert
Assert.NotNull(client);
// We could add more assertions here about the client's configuration
}
[Fact]
public async Task GetClientAsync_CalledTwice_ReturnsSameInstance()
{
// Arrange
var config = new McpServerConfig
{
Id = "test-server",
Name = "Test Server",
TransportType = "stdio",
Location = "/path/to/server"
};
// Create a mock transport
var mockTransport = new Mock<IMcpTransport>();
mockTransport.Setup(t => t.ConnectAsync(It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
mockTransport.Setup(t => t.IsConnected).Returns(true);
mockTransport.Setup(t => t.MessageReader).Returns(Mock.Of<ChannelReader<IJsonRpcMessage>>());
// Create a mock client
var mockClient = new Mock<IMcpClient>();
mockClient.Setup(c => c.ConnectAsync(It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
mockClient.Setup(c => c.IsInitialized).Returns(true);
var factory = new McpClientFactory([config],
_defaultOptions,
NullLoggerFactory.Instance,
transportFactoryMethod: _ => mockTransport.Object,
clientFactoryMethod: (_,_,_) => mockClient.Object);
// Act
var client1 = await factory.GetClientAsync("test-server");
var client2 = await factory.GetClientAsync("test-server");
// Assert
Assert.Same(client1, client2);
}
[Fact]
public async Task GetClientAsync_WithInvalidServerId_ThrowsArgumentException()
{
// Arrange
var factory = new McpClientFactory(Array.Empty<McpServerConfig>(),
_defaultOptions,
NullLoggerFactory.Instance);
// Act & Assert
await Assert.ThrowsAsync<ArgumentException>(
() => factory.GetClientAsync("non-existent-server")
);
}
[Fact]
public async Task GetClientAsync_WithUnsupportedTransport_ThrowsArgumentException()
{
// Arrange
var config = new McpServerConfig
{
Id = "test-server",
Name = "Test Server",
TransportType = "unsupported",
Location = "/path/to/server"
};
var factory = new McpClientFactory([config], _defaultOptions,
NullLoggerFactory.Instance);
// Act & Assert
await Assert.ThrowsAsync<ArgumentException>(
() => factory.GetClientAsync("test-server")
);
}
[Fact]
public async Task GetClientAsync_WithNoTransportOptions_CreatesClientWithDefaults()
{
// Arrange
var config = new McpServerConfig
{
Id = "test-server",
Name = "Test Server",
TransportType = "stdio",
Location = "/path/to/server"
};
// Create a mock transport
var mockTransport = new Mock<IMcpTransport>();
mockTransport.Setup(t => t.ConnectAsync(It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
mockTransport.Setup(t => t.IsConnected).Returns(true);
mockTransport.Setup(t => t.MessageReader).Returns(Mock.Of<ChannelReader<IJsonRpcMessage>>());
// Create a mock client
var mockClient = new Mock<IMcpClient>();
mockClient.Setup(c => c.ConnectAsync(It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
mockClient.Setup(c => c.IsInitialized).Returns(true);
var factory = new McpClientFactory([config],
_defaultOptions,
NullLoggerFactory.Instance,
transportFactoryMethod: _ => mockTransport.Object,
clientFactoryMethod: (_, _, _) => mockClient.Object);
// Act
var client = await factory.GetClientAsync("test-server");
// Assert
Assert.NotNull(client);
}
[Fact]
public void Constructor_WithDuplicateServerIds_ThrowsArgumentException()
{
// Arrange
McpServerConfig[] configs =
[
new McpServerConfig { Id = "duplicate", Name = "duplicate", TransportType = "stdio", Location = "/path1" },
new McpServerConfig { Id = "duplicate", Name = "duplicate", TransportType = "stdio", Location = "/path2" }
];
// Act & Assert
Assert.Throws<ArgumentException>(() => new McpClientFactory(configs, _defaultOptions,
NullLoggerFactory.Instance));
}
[Fact]
public async Task GetClientAsync_WithSseTransport_CanCreateClient()
{
// Arrange
var config = new McpServerConfig
{
Id = "test-server",
Name = "Test Server",
TransportType = "sse",
Location = "http://localhost:8080"
};
// Create a mock transport
var mockTransport = new Mock<IMcpTransport>();
mockTransport.Setup(t => t.ConnectAsync(It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
mockTransport.Setup(t => t.IsConnected).Returns(true);
mockTransport.Setup(t => t.MessageReader).Returns(Mock.Of<ChannelReader<IJsonRpcMessage>>());
// Create a mock client
var mockClient = new Mock<IMcpClient>();
mockClient.Setup(c => c.ConnectAsync(It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
mockClient.Setup(c => c.IsInitialized).Returns(true);
// Inject the mock transport into the factory
var factory = new McpClientFactory(
[config],
_defaultOptions,
NullLoggerFactory.Instance,
transportFactoryMethod: _ => mockTransport.Object,
clientFactoryMethod: (_, _, _) => mockClient.Object
);
// Act
var client = await factory.GetClientAsync("test-server");
// Assert
Assert.NotNull(client);
}
[Fact]
public void McpFactory_WithSse_CreatesCorrectTransportOptions()
{
// Arrange
var config = new McpServerConfig
{
Id = "test-server",
Name = "Test Server",
TransportType = "sse",
Location = "http://localhost:8080",
TransportOptions = new Dictionary<string, string>
{
["connectionTimeout"] = "10",
["maxReconnectAttempts"] = "2",
["reconnectDelay"] = "5",
["header.test"] = "the_header_value"
}
};
// Act
var factory = new McpClientFactory(
[config],
_defaultOptions,
NullLoggerFactory.Instance
);
var transport = factory.TransportFactoryMethod(config) as SseTransport;
// Assert
Assert.NotNull(transport);
Assert.True(transport.Options.ConnectionTimeout == TimeSpan.FromSeconds(10));
Assert.True(transport.Options.MaxReconnectAttempts == 2);
Assert.True(transport.Options.ReconnectDelay == TimeSpan.FromSeconds(5));
Assert.True(transport.Options.AdditionalHeaders["test"] == "the_header_value");
}
[Fact]
public void McpFactory_WithSseAndNoOptions_CreatesDefaultTransportOptions()
{
// Arrange
var config = new McpServerConfig
{
Id = "test-server",
Name = "Test Server",
TransportType = "sse",
Location = "http://localhost:8080"
};
var defaultOptions = new SseTransportOptions();
// Act
var factory = new McpClientFactory(
[config],
_defaultOptions,
NullLoggerFactory.Instance
);
var transport = factory.TransportFactoryMethod(config) as SseTransport;
// Assert
Assert.NotNull(transport);
Assert.True(transport.Options.ConnectionTimeout == defaultOptions.ConnectionTimeout);
Assert.True(transport.Options.MaxReconnectAttempts == defaultOptions.MaxReconnectAttempts);
Assert.True(transport.Options.ReconnectDelay == defaultOptions.ReconnectDelay);
Assert.True(transport.Options.AdditionalHeaders == null && defaultOptions.AdditionalHeaders == null);
}
[Fact]
public void McpFactory_WithSseAndMissingOptions_CreatesCorrectTransportOptions()
{
// Arrange
var config = new McpServerConfig
{
Id = "test-server",
Name = "Test Server",
TransportType = "sse",
Location = "http://localhost:8080",
TransportOptions = new Dictionary<string, string>
{
["connectionTimeout"] = "10",
["header.test"] = "the_header_value"
}
};
var defaultOptions = new SseTransportOptions();
// Act
var factory = new McpClientFactory(
[config],
_defaultOptions,
NullLoggerFactory.Instance
);
var transport = factory.TransportFactoryMethod(config) as SseTransport;
// Assert
Assert.NotNull(transport);
Assert.True(transport.Options.ConnectionTimeout == TimeSpan.FromSeconds(10));
Assert.True(transport.Options.MaxReconnectAttempts == defaultOptions.MaxReconnectAttempts);
Assert.True(transport.Options.ReconnectDelay == defaultOptions.ReconnectDelay);
Assert.True(transport.Options.AdditionalHeaders["test"] == "the_header_value");
}
[Theory]
[InlineData("connectionTimeout", "not_a_number")]
[InlineData("maxReconnectAttempts", "invalid")]
[InlineData("reconnectDelay", "bad_value")]
public void McpFactory_WithInvalidTransportOptions_ThrowsFormatException(string key, string value)
{
// arrange
var config = new McpServerConfig
{
Id = "test-server",
Name = "Test Server",
TransportType = "sse",
Location = "http://localhost:8080",
TransportOptions = new Dictionary<string, string>
{
[key] = value
}
};
// Act
var factory = new McpClientFactory(
[config],
_defaultOptions,
NullLoggerFactory.Instance
);
// act & assert
Assert.Throws<FormatException>(() =>
factory.TransportFactoryMethod(config));
}
}

View file

@ -1,164 +0,0 @@
using McpDotNet.Client;
using McpDotNet.Configuration;
using McpDotNet.Protocol.Messages;
using McpDotNet.Protocol.Transport;
using McpDotNet.Tests.Utils;
using Microsoft.Extensions.Logging;
using System.Text.Json;
namespace McpDotNet.Tests;
public class SseIntegrationTests
{
[Fact]
public async Task ConnectAndReceiveMessage_InMemoryServer()
{
// Arrange
using var loggerFactory = Microsoft.Extensions.Logging.LoggerFactory.Create(builder =>
builder.AddConsole()
.SetMinimumLevel(LogLevel.Debug));
await using TestSseServer server = new(logger:loggerFactory.CreateLogger<TestSseServer>());
await server.StartAsync();
var defaultOptions = new McpClientOptions
{
ClientInfo = new() { Name = "IntegrationTestClient", Version = "1.0.0" }
};
var defaultConfig = new McpServerConfig
{
Id = "test_server",
Name = "In-memory Test Server",
TransportType = "sse",
TransportOptions = new Dictionary<string, string>(),
Location = "http://localhost:5000/sse"
};
var factory = new McpClientFactory(
[defaultConfig],
defaultOptions,
loggerFactory
);
// Act
var client = await factory.GetClientAsync("test_server");
// Wait for SSE connection to be established
await server.WaitForConnectionAsync(TimeSpan.FromSeconds(10));
// Send a test message through POST endpoint
var testMessage = new JsonRpcRequest()
{
Id = RequestId.FromNumber(1),
Method = "test/message",
Params = new { message = "Hello, SSE!" }
};
using var httpClient = new HttpClient();
await httpClient.PostAsync(
"http://localhost:5000/message",
new StringContent(JsonSerializer.Serialize(testMessage))
);
// Assert
Assert.True(true);
}
[Fact]
public async Task ConnectAndReceiveNotification_InMemoryServer()
{
// Arrange
using var loggerFactory = Microsoft.Extensions.Logging.LoggerFactory.Create(builder =>
builder.AddConsole()
.SetMinimumLevel(LogLevel.Debug));
await using TestSseServer server = new(logger: loggerFactory.CreateLogger<TestSseServer>());
await server.StartAsync();
var defaultOptions = new McpClientOptions
{
ClientInfo = new() { Name = "IntegrationTestClient", Version = "1.0.0" }
};
var defaultConfig = new McpServerConfig
{
Id = "test_server",
Name = "In-memory Test Server",
TransportType = "sse",
TransportOptions = new Dictionary<string, string>(),
Location = "http://localhost:5000/sse"
};
var factory = new McpClientFactory(
[defaultConfig],
defaultOptions,
loggerFactory
);
// Act
var client = await factory.GetClientAsync("test_server");
// Wait for SSE connection to be established
await server.WaitForConnectionAsync(TimeSpan.FromSeconds(10));
var receivedNotification = new TaskCompletionSource<string>();
client.OnNotification("test/notification", async (args) =>
{
var msg = ((JsonElement)args.Params).GetProperty("message").GetString();
receivedNotification.SetResult(msg);
});
// Act
await server.SendTestNotificationAsync("Hello from server!");
// Assert
var message = await receivedNotification.Task.WaitAsync(TimeSpan.FromSeconds(10));
Assert.Equal("Hello from server!", message);
}
[Fact]
public async Task ConnectTwice_Throws()
{
// Arrange
using var loggerFactory = Microsoft.Extensions.Logging.LoggerFactory.Create(builder =>
builder.AddConsole()
.SetMinimumLevel(LogLevel.Debug));
await using TestSseServer server = new(logger: loggerFactory.CreateLogger<TestSseServer>());
await server.StartAsync();
var defaultOptions = new McpClientOptions
{
ClientInfo = new() { Name = "IntegrationTestClient", Version = "1.0.0" }
};
var defaultConfig = new McpServerConfig
{
Id = "test_server",
Name = "In-memory Test Server",
TransportType = "sse",
TransportOptions = new Dictionary<string, string>(),
Location = "http://localhost:5000/sse"
};
var factory = new McpClientFactory(
[defaultConfig],
defaultOptions,
loggerFactory
);
// Act
var client = await factory.GetClientAsync("test_server");
var mcpClient = (McpClient)client;
var transport = (SseTransport)mcpClient.Transport;
// Wait for SSE connection to be established
await server.WaitForConnectionAsync(TimeSpan.FromSeconds(10));
// Assert
await Assert.ThrowsAsync<McpTransportException>(async () => await transport.ConnectAsync());
}
}

View file

@ -1,74 +0,0 @@
using McpDotNet.Configuration;
using McpDotNet.Protocol.Messages;
using McpDotNet.Protocol.Transport;
using Microsoft.Extensions.Logging.Abstractions;
namespace McpDotNet.Tests.Client;
public class SseTransportTests
{
[Fact]
public void SseTransportConstructor_WithValidConfig_CreatesTransport()
{
// Arrange
var config = new McpServerConfig
{
Id = "test-server",
Name = "Test Server",
TransportType = "sse",
Location = "http://localhost:8080"
};
var transportOptions = new SseTransportOptions
{
ConnectionTimeout = TimeSpan.FromSeconds(10),
MaxReconnectAttempts = 2,
ReconnectDelay = TimeSpan.FromSeconds(5),
AdditionalHeaders = new Dictionary<string, string>
{
["test"] = "header"
}
};
// Act
var transport = new SseTransport(transportOptions, config, NullLoggerFactory.Instance);
// Assert
Assert.NotNull(transport);
Assert.True(transport.Options.ConnectionTimeout == TimeSpan.FromSeconds(10));
Assert.True(transport.Options.MaxReconnectAttempts == 2);
Assert.True(transport.Options.ReconnectDelay == TimeSpan.FromSeconds(5));
Assert.True(transport.Options.AdditionalHeaders["test"] == "header");
}
[Fact]
public async void SseTransportSendMessageAsync_WithMessageEndpointNotSet_ThrowsException()
{
// Arrange
var config = new McpServerConfig
{
Id = "test-server",
Name = "Test Server",
TransportType = "sse",
Location = "http://localhost:8080"
};
var transportOptions = new SseTransportOptions
{
ConnectionTimeout = TimeSpan.FromSeconds(10),
MaxReconnectAttempts = 2,
ReconnectDelay = TimeSpan.FromSeconds(5),
AdditionalHeaders = new Dictionary<string, string>
{
["test"] = "header"
}
};
// Act
var transport = new SseTransport(transportOptions, config, NullLoggerFactory.Instance);
// Assert
Assert.True(string.IsNullOrEmpty(transport.MessageEndpoint?.ToString()));
await Assert.ThrowsAsync<InvalidOperationException>(() => transport.SendMessageAsync(new JsonRpcRequest() { Method = "test"}, CancellationToken.None));
}
}

View file

@ -1,354 +0,0 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Logging;
using System.Collections.Concurrent;
using System.Net;
using System.Text.Json;
using McpDotNet.Protocol.Messages;
namespace McpDotNet.Tests.Utils;
public sealed class TestSseServer : IAsyncDisposable
{
private readonly HttpListener _listener;
private readonly CancellationTokenSource _cts;
private readonly ILogger<TestSseServer> _logger;
private Task? _serverTask;
private readonly TaskCompletionSource _connectionEstablished = new();
// SSE endpoint for GET
private readonly string _endpointPath;
// POST endpoint
private readonly string _messagePath;
// Keep track of all open SSE connections (StreamWriters).
private readonly ConcurrentBag<StreamWriter> _sseClients = new();
public TestSseServer(int port = 5000, ILogger<TestSseServer>? logger = null)
{
_listener = new HttpListener();
_listener.Prefixes.Add($"http://localhost:{port}/");
_cts = new CancellationTokenSource();
_logger = logger ?? NullLogger<TestSseServer>.Instance;
_endpointPath = "/sse";
_messagePath = "/message";
}
/// <summary>
/// Full URL for the SSE endpoint, e.g. "http://localhost:5000/sse"
/// </summary>
public string SseEndpoint
=> $"http://localhost:{_listener.Prefixes.First().Split(':')[2].TrimEnd('/')}{_endpointPath}";
/// <summary>
/// Full URL for the message endpoint, e.g. "http://localhost:5000/message"
/// </summary>
public string MessageEndpoint
=> $"http://localhost:{_listener.Prefixes.First().Split(':')[2].TrimEnd('/')}{_messagePath}";
/// <summary>
/// Starts the server so it can accept incoming connections and POST requests.
/// </summary>
public async Task StartAsync()
{
_listener.Start();
_serverTask = HandleConnectionsAsync(_cts.Token);
_logger.LogInformation("Test SSE server started on {Endpoint}", SseEndpoint);
await Task.CompletedTask;
}
private async Task HandleConnectionsAsync(CancellationToken ct)
{
try
{
while (!ct.IsCancellationRequested)
{
var context = await _listener.GetContextAsync();
_ = Task.Run(() => HandleRequestAsync(context, ct), ct);
}
}
catch (OperationCanceledException)
{
// Ignore, we are shutting down
}
catch (Exception ex) when (!ct.IsCancellationRequested)
{
_logger.LogError(ex, "Error in SSE server connection handling");
}
}
private async Task HandleRequestAsync(HttpListenerContext context, CancellationToken ct)
{
var request = context.Request;
var response = context.Response;
// Handle SSE endpoint
if (request.HttpMethod.Equals("GET", StringComparison.OrdinalIgnoreCase)
&& request.Url.AbsolutePath.Equals(_endpointPath, StringComparison.OrdinalIgnoreCase))
{
await HandleSseConnectionAsync(context, ct);
}
// Handle POST /message
else if (request.HttpMethod.Equals("POST", StringComparison.OrdinalIgnoreCase)
&& request.Url.AbsolutePath.Equals(_messagePath, StringComparison.OrdinalIgnoreCase))
{
await HandlePostMessageAsync(context, ct);
}
else
{
response.StatusCode = 404;
response.Close();
}
}
/// <summary>
/// Handle Server-Sent Events (SSE) connection.
/// Send the initial event: endpoint with the full POST URL.
/// Keep the connection open until the server is disposed or the client disconnects.
/// </summary>
private async Task HandleSseConnectionAsync(HttpListenerContext context, CancellationToken ct)
{
var response = context.Response;
response.ContentType = "text/event-stream";
response.Headers["Cache-Control"] = "no-cache";
// Ensures the response is never chunked away by the framework
response.SendChunked = true;
response.StatusCode = (int)HttpStatusCode.OK;
using var writer = new StreamWriter(response.OutputStream);
_sseClients.Add(writer);
// Immediately send the "endpoint" event with the POST URL
var endpointData = new
{
uri = MessageEndpoint
};
var json = JsonSerializer.Serialize(endpointData);
await writer.WriteLineAsync("event: endpoint");
await writer.WriteLineAsync($"data: {json}");
await writer.WriteLineAsync(); // blank line to end an SSE message
await writer.FlushAsync();
_logger.LogInformation("New SSE client connected.");
_connectionEstablished.TrySetResult(); // Signal connection is ready
try
{
// Keep the connection open by "pinging" or just waiting
// until the client disconnects or the server is canceled.
while (!ct.IsCancellationRequested && response.OutputStream.CanWrite)
{
_logger.LogDebug("SSE connection alive check");
// Optionally do a periodic no-op to keep connection alive:
await writer.WriteLineAsync(": keep-alive");
await writer.FlushAsync();
await Task.Delay(TimeSpan.FromSeconds(5), ct);
}
}
catch (TaskCanceledException)
{
// This is expected on shutdown
_logger.LogInformation("SSE connection cancelled (expected on shutdown)");
}
catch (IOException)
{
// Client likely disconnected
_logger.LogInformation("SSE client disconnected");
}
finally
{
// Remove this writer from bag (we're disposing it anyway)
_sseClients.TryTake(out _);
_logger.LogInformation("SSE client disconnected.");
}
}
// Add method to wait for connection
public Task WaitForConnectionAsync(TimeSpan timeout) =>
_connectionEstablished.Task.WaitAsync(timeout);
/// <summary>
/// Handle POST /message endpoint.
/// Echo the content back to the caller and broadcast it over SSE as well.
/// </summary>
private async Task HandlePostMessageAsync(HttpListenerContext context, CancellationToken ct)
{
var request = context.Request;
var response = context.Response;
try
{
using var reader = new StreamReader(request.InputStream);
string content = await reader.ReadToEndAsync();
var jsonRpcRequest = JsonSerializer.Deserialize<JsonRpcRequest>(content);
if (jsonRpcRequest != null && !jsonRpcRequest.Method.StartsWith("notification"))
{
if (jsonRpcRequest.Method == "initialize")
{
await HandleInitializationRequest(response, jsonRpcRequest);
return;
}
// Method not found - proper JSON-RPC error response
await SendJsonRpcErrorAsync(response, jsonRpcRequest.Id, -32601, $"Method '{jsonRpcRequest.Method}' not found");
return;
}
var notification = JsonSerializer.Deserialize<JsonRpcNotification>(content);
if (notification != null)
{
response.StatusCode = 200;
response.ContentType = "application/json";
await using var writer = new StreamWriter(response.OutputStream);
await writer.WriteAsync(JsonSerializer.Serialize(new { jsonrpc = "2.0" }));
return;
}
// Invalid JSON-RPC message - proper JSON-RPC error response
await SendJsonRpcErrorAsync(response, null, -32700, "Parse error");
}
catch (Exception ex)
{
await SendJsonRpcErrorAsync(response, null, -32603, "Internal error", ex.Message);
response.StatusCode = 500;
}
finally
{
response.Close();
}
}
private async Task SendJsonRpcErrorAsync(HttpListenerResponse response, RequestId? id, int code, string message, string? data = null)
{
var errorResponse = new JsonRpcError
{
Id = id ?? RequestId.FromString("error"),
JsonRpc = "2.0",
Error = new JsonRpcErrorDetail
{
Code = code,
Message = message,
Data = data
}
};
response.StatusCode = 200; // Always 200 for JSON-RPC
response.ContentType = "application/json";
await using var writer = new StreamWriter(response.OutputStream);
await writer.WriteAsync(JsonSerializer.Serialize(errorResponse));
}
private async Task HandleInitializationRequest(HttpListenerResponse response, JsonRpcRequest jsonRpcRequest)
{
// We don't need to validate the client's initialization request for the test
// Just send back a valid server initialization response
var jsonResponse = new
{
protocolVersion = "2024-11-05",
capabilities = new
{
experimental = (object?)null,
roots = (object?)null,
sampling = (object?)null
}
};
var jsonRpcResponse = new JsonRpcResponse()
{
Id = jsonRpcRequest.Id,
Result = new
{
protocolVersion = "2024-11-05",
capabilities = new
{
experimental = (object?)null,
roots = (object?)null,
sampling = (object?)null
},
serverInfo = new
{
name = "ExampleServer",
version = "1.0.0"
}
}
};
// Echo back to the HTTP response
response.StatusCode = 200;
response.ContentType = "application/json";
await using var writer = new StreamWriter(response.OutputStream);
await writer.WriteAsync(JsonSerializer.Serialize(jsonRpcResponse));
}
/// <summary>
/// Broadcast a message to all currently connected SSE clients.
/// </summary>
/// <param name="message">The raw string to send</param>
public async Task BroadcastMessageAsync(string message)
{
foreach (var client in _sseClients.ToArray()) // ToArray to avoid mutation issues
{
try
{
// SSE requires "event: <name>" + "data: <payload>" + blank line
await client.WriteLineAsync("event: message");
await client.WriteLineAsync($"data: {message}");
await client.WriteLineAsync();
await client.FlushAsync();
}
catch (IOException)
{
// Client may have disconnected. We let them get cleaned up on next iteration.
}
catch (ObjectDisposedException)
{
// Stream is disposed, ignore.
}
}
}
public async ValueTask DisposeAsync()
{
_cts.Cancel();
if (_serverTask != null)
{
try
{
await _serverTask;
}
catch (TaskCanceledException)
{
// ignore
}
}
_listener.Close();
_cts.Dispose();
_logger.LogInformation("Test SSE server stopped");
}
/// <summary>
/// Send a test notification to all connected SSE clients.
/// </summary>
/// <param name="content"></param>
/// <returns></returns>
public async Task SendTestNotificationAsync(string content)
{
var notification = new JsonRpcNotification
{
JsonRpc = "2.0",
Method = "test/notification",
Params = new { message = content }
};
var serialized = JsonSerializer.Serialize(notification);
await BroadcastMessageAsync(serialized);
}
}

View file

@ -1,32 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="9.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.0">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="6.0.3">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\mcpdotnet\mcpdotnet.csproj" />
</ItemGroup>
</Project>

View file

@ -1,31 +0,0 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.13.35507.96
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "mcpdotnet", "mcpdotnet\mcpdotnet.csproj", "{12260CD2-AFFC-4B2E-8898-F442CAA1FA0F}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "mcpdotnet.Tests", "mcpdotnet.Tests\mcpdotnet.Tests.csproj", "{FF41F619-833D-4FA2-8C53-04D0A1D5AA61}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{12260CD2-AFFC-4B2E-8898-F442CAA1FA0F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{12260CD2-AFFC-4B2E-8898-F442CAA1FA0F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{12260CD2-AFFC-4B2E-8898-F442CAA1FA0F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{12260CD2-AFFC-4B2E-8898-F442CAA1FA0F}.Release|Any CPU.Build.0 = Release|Any CPU
{FF41F619-833D-4FA2-8C53-04D0A1D5AA61}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{FF41F619-833D-4FA2-8C53-04D0A1D5AA61}.Debug|Any CPU.Build.0 = Debug|Any CPU
{FF41F619-833D-4FA2-8C53-04D0A1D5AA61}.Release|Any CPU.ActiveCfg = Release|Any CPU
{FF41F619-833D-4FA2-8C53-04D0A1D5AA61}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {384A3888-751F-4D75-9AE5-587330582D89}
EndGlobalSection
EndGlobal

View file

@ -84,6 +84,7 @@ public sealed class SseTransport : TransportBase
}
}
/// <inheritdoc/>
public override async Task SendMessageAsync(
IJsonRpcMessage message,
CancellationToken cancellationToken = default)

View file

@ -1,7 +1,7 @@
namespace McpDotNet.Protocol.Types;

using System.Text.Json.Serialization;
namespace McpDotNet.Protocol.Types;
/// <summary>
/// Represents annotations that can be attached to content.
/// </summary>

View file

@ -9,10 +9,12 @@ public class CallToolResponse
/// <summary>
/// The server's response to a tools/call request from the client.
/// </summary>
[System.Text.Json.Serialization.JsonPropertyName("content")]
public List<Content> Content { get; set; } = new();
/// <summary>
/// Whether the tool call was unsuccessful. If true, the call was unsuccessful.
/// </summary>
[System.Text.Json.Serialization.JsonPropertyName("isError")]
public bool IsError { get; set; }
}

View file

@ -1,7 +1,7 @@
namespace McpDotNet.Protocol.Types;

using System.Text.Json.Serialization;
namespace McpDotNet.Protocol.Types;
/// <summary>
/// Represents the capabilities that a client may support.
/// </summary>

View file

@ -31,7 +31,7 @@ public class Content
/// <summary>
/// The MIME type of the image.
/// </summary>
[JsonPropertyName("mime_type")]
[JsonPropertyName("mimeType")]
public string? MimeType { get; set; }
/// <summary>

View file

@ -13,40 +13,48 @@ public class CreateMessageRequestParams
/// <summary>
/// A request to include context from one or more MCP servers (including the caller), to be attached to the prompt. The client MAY ignore this request.
/// </summary>
[System.Text.Json.Serialization.JsonPropertyName("includeContext")]
public ContextInclusion? IncludeContext { get; init; }
/// <summary>
/// The maximum number of tokens to sample, as requested by the server. The client MAY choose to sample fewer tokens than requested.
/// </summary>
[System.Text.Json.Serialization.JsonPropertyName("maxTokens")]
public int MaxTokens { get; init; }
/// <summary>
/// Messages requested by the server to be included in the prompt.
/// </summary>
[System.Text.Json.Serialization.JsonPropertyName("messages")]
public required IReadOnlyList<SamplingMessage> Messages { get; init; }
/// <summary>
/// Optional metadata to pass through to the LLM provider. The format of this metadata is provider-specific.
/// </summary>
[System.Text.Json.Serialization.JsonPropertyName("metadata")]
public object? Metadata { get; init; }
/// <summary>
/// The server's preferences for which model to select. The client MAY ignore these preferences.
/// </summary>
[System.Text.Json.Serialization.JsonPropertyName("modelPreferences")]
public ModelPreferences? ModelPreferences { get; init; }
/// <summary>
/// Optional stop sequences that the server wants to use for sampling.
/// </summary>
[System.Text.Json.Serialization.JsonPropertyName("stopSequences")]
public IReadOnlyList<string>? StopSequences { get; init; }
/// <summary>
/// An optional system prompt the server wants to use for sampling. The client MAY modify or omit this prompt.
/// </summary>
[System.Text.Json.Serialization.JsonPropertyName("systemPrompt")]
public string? SystemPrompt { get; init; }
/// <summary>
/// The temperature to use for sampling, as requested by the server.
/// </summary>
[System.Text.Json.Serialization.JsonPropertyName("temperature")]
public float? Temperature { get; init; }
}

View file

@ -24,7 +24,7 @@ public class CreateMessageResult
/// <summary>
/// The reason why sampling stopped, if known.
/// </summary>
[JsonPropertyName("stop_reason")]
[JsonPropertyName("stopReason")]
public string? StopReason { get; init; }
/// <summary>

View file

@ -9,10 +9,12 @@ public class GetPromptResult
/// <summary>
/// An optional description for the prompt.
/// </summary>
[System.Text.Json.Serialization.JsonPropertyName("description")]
public string? Description { get; set; }
/// <summary>
/// The prompt or prompt template that the server offers.
/// </summary>
[System.Text.Json.Serialization.JsonPropertyName("messages")]
public List<PromptMessage> Messages { get; set; } = new();
}

View file

@ -9,15 +9,18 @@ public class JsonSchema
/// <summary>
/// The type of the schema, should be "object".
/// </summary>
[System.Text.Json.Serialization.JsonPropertyName("type")]
public string Type { get; set; } = string.Empty;
/// <summary>
/// Map of property names to property definitions.
/// </summary>
[System.Text.Json.Serialization.JsonPropertyName("properties")]
public Dictionary<string, JsonSchemaProperty>? Properties { get; set; }
/// <summary>
/// List of required property names.
/// </summary>
[System.Text.Json.Serialization.JsonPropertyName("required")]
public List<string>? Required { get; set; }
}

View file

@ -9,10 +9,12 @@ public class JsonSchemaProperty
/// <summary>
/// The type of the property. Should be a JSON Schema type and is required.
/// </summary>
[System.Text.Json.Serialization.JsonPropertyName("type")]
public string Type { get; set; } = string.Empty;
/// <summary>
/// A human-readable description of the property.
/// </summary>
[System.Text.Json.Serialization.JsonPropertyName("description")]
public string? Description { get; set; }
}

View file

@ -11,5 +11,6 @@ public class ListPromptsResult : PaginatedResult
/// <summary>
/// A list of prompts or prompt templates that the server offers.
/// </summary>
[System.Text.Json.Serialization.JsonPropertyName("prompts")]
public List<Prompt> Prompts { get; set; } = new();
}

Some files were not shown because too many files have changed in this diff Show more