ProtocolTests.cs 3.6 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192
  1. using ModelContextProtocol;
  2. using ModelContextProtocol.Client;
  3. using RackPeek.Mcp.Tools;
  4. namespace Tests.Mcp;
  5. /// <summary>
  6. /// The protocol surface itself: the handshake identifies the server, every tool is
  7. /// advertised with enough description for an agent to use it unseen, and a bad tool
  8. /// name is an error rather than a hang or a crash.
  9. /// </summary>
  10. public class ProtocolTests {
  11. /// <summary>Every tool the server ships. A rename here is a breaking change for clients.</summary>
  12. public static readonly string[] ExpectedTools = [
  13. "list_resources", "get_resource", "search_resources", "get_summary", "get_tree",
  14. "list_connections", "get_subnets", "get_schema",
  15. "upsert_resources", "delete_resource", "rename_resource", "clone_resource",
  16. "edit_tags", "edit_labels", "add_connection", "remove_connection",
  17. "export_ansible_inventory", "export_ssh_config", "export_hosts_file", "export_topology_mermaid",
  18. "git_status", "git_commit"
  19. ];
  20. [Fact]
  21. public async Task The_handshake_identifies_the_server_by_name_and_version() {
  22. using var api = new McpFixture();
  23. await using McpClient client = await api.ConnectAsync();
  24. Assert.NotNull(client.ServerInfo);
  25. Assert.Equal("rackpeek", client.ServerInfo!.Name);
  26. Assert.False(string.IsNullOrWhiteSpace(client.ServerInfo!.Version));
  27. }
  28. [Fact]
  29. public async Task Every_expected_tool_is_advertised_and_nothing_else() {
  30. using var api = new McpFixture();
  31. await using McpClient client = await api.ConnectAsync();
  32. IList<McpClientTool> tools = await client.ListToolsAsync();
  33. Assert.Equal(
  34. ExpectedTools.OrderBy(t => t, StringComparer.Ordinal),
  35. tools.Select(t => t.Name).OrderBy(t => t, StringComparer.Ordinal));
  36. }
  37. [Fact]
  38. public async Task Every_tool_carries_a_description_an_agent_can_act_on() {
  39. using var api = new McpFixture();
  40. await using McpClient client = await api.ConnectAsync();
  41. IList<McpClientTool> tools = await client.ListToolsAsync();
  42. foreach (McpClientTool tool in tools)
  43. Assert.False(
  44. string.IsNullOrWhiteSpace(tool.Description),
  45. $"Tool '{tool.Name}' has no description.");
  46. }
  47. [Fact]
  48. public async Task Calling_a_tool_that_does_not_exist_is_a_clean_error() {
  49. using var api = new McpFixture();
  50. await using McpClient client = await api.ConnectAsync();
  51. await Assert.ThrowsAnyAsync<McpException>(async () =>
  52. await client.CallToolAsync("does_not_exist"));
  53. }
  54. [Theory]
  55. [InlineData("discover_docker")]
  56. [InlineData("discover_proxmox")]
  57. public async Task Discovery_tools_are_not_available_over_mcp(string toolName) {
  58. using var api = new McpFixture();
  59. await using McpClient client = await api.ConnectAsync();
  60. IList<McpClientTool> tools = await client.ListToolsAsync();
  61. Assert.DoesNotContain(tools, tool => tool.Name == toolName);
  62. await Assert.ThrowsAnyAsync<McpException>(async () =>
  63. await client.CallToolAsync(toolName));
  64. }
  65. [Fact]
  66. public async Task Two_clients_can_talk_to_the_same_server_at_once() {
  67. // Stateless streamable HTTP: no session to collide on.
  68. using var api = new McpFixture();
  69. await using McpClient first = await api.ConnectAsync();
  70. await using McpClient second = await api.ConnectAsync();
  71. await Task.WhenAll(
  72. first.CallOkAsync<ResourceList>("list_resources"),
  73. second.CallOkAsync<ResourceList>("list_resources"));
  74. }
  75. }