Reorganize folders, samples, minor bug fix (#14)
Reorganize folders Samples Casing fixes
This commit is contained in:
parent
e51c78e5e4
commit
3e35efe79c
35 changed files with 501 additions and 20 deletions
|
|
@ -1 +0,0 @@
|
|||
global using Xunit;
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
|
|
@ -84,6 +84,7 @@ public sealed class SseTransport : TransportBase
|
|||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task SendMessageAsync(
|
||||
IJsonRpcMessage message,
|
||||
CancellationToken cancellationToken = default)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue