McpE2eTests.cs 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182
  1. using DotNet.Testcontainers.Builders;
  2. using DotNet.Testcontainers.Containers;
  3. using ModelContextProtocol.Client;
  4. using ModelContextProtocol.Protocol;
  5. using System.Text.Json;
  6. namespace Tests.E2e;
  7. /// <summary>
  8. /// The full production path: the shipped Docker image, a real network port, and a
  9. /// real MCP client walking one realistic session — learn the schema, preview a
  10. /// change, apply it, query it back, wire a connection, export, delete. Everything
  11. /// the in-process suite (Tests.Mcp) proves is re-proved here against the artefact
  12. /// that is actually distributed.
  13. /// </summary>
  14. public class McpE2eTests : IAsyncLifetime {
  15. private const string _dockerImage = "rackpeek:ci";
  16. private const string _apiKey = "e2e-mcp-key";
  17. private IContainer _container = default!;
  18. private HttpClient _http = default!;
  19. private McpClient _client = default!;
  20. public async Task InitializeAsync() {
  21. _container = new ContainerBuilder(_dockerImage)
  22. .WithPortBinding(8080, true)
  23. .WithEnvironment("RPK_API_KEY", _apiKey)
  24. .WithWaitStrategy(
  25. Wait.ForUnixContainer()
  26. .UntilHttpRequestIsSucceeded(r => r
  27. .ForPort(8080)
  28. .ForPath("/health")))
  29. .Build();
  30. await _container.StartAsync();
  31. _http = new HttpClient();
  32. _http.DefaultRequestHeaders.Add("X-Api-Key", _apiKey);
  33. var transport = new HttpClientTransport(
  34. new HttpClientTransportOptions {
  35. Endpoint = new Uri($"http://127.0.0.1:{_container.GetMappedPublicPort(8080)}/mcp"),
  36. TransportMode = HttpTransportMode.StreamableHttp
  37. },
  38. _http,
  39. loggerFactory: null,
  40. ownsHttpClient: true);
  41. _client = await McpClient.CreateAsync(transport);
  42. }
  43. public async Task DisposeAsync() {
  44. if (_client != null) await _client.DisposeAsync();
  45. if (_container != null) await _container.DisposeAsync();
  46. }
  47. private async Task<JsonElement> CallAsync(string tool, Dictionary<string, object?>? args = null) {
  48. CallToolResult result = await _client.CallToolAsync(tool, args);
  49. var text = string.Join("\n", result.Content.OfType<TextContentBlock>().Select(t => t.Text));
  50. Assert.False(result.IsError == true, $"'{tool}' failed: {text}");
  51. return result.StructuredContent
  52. ?? JsonDocument.Parse(JsonSerializer.Serialize(new { text })).RootElement;
  53. }
  54. [Fact]
  55. public async Task A_full_session_builds_queries_wires_exports_and_deletes_a_stack() {
  56. // The server advertises itself and its whole tool surface over the wire.
  57. Assert.Equal("rackpeek", _client.ServerInfo.Name);
  58. IList<McpClientTool> tools = await _client.ListToolsAsync();
  59. Assert.Contains(tools, t => t.Name == "upsert_resources");
  60. Assert.Contains(tools, t => t.Name == "discover_docker");
  61. // 1. Learn the format.
  62. JsonElement schema = await CallAsync("get_schema");
  63. Assert.Equal(4, schema.GetProperty("version").GetInt32());
  64. // 2. Preview, then apply, a small stack.
  65. const string stack =
  66. """
  67. version: 4
  68. resources:
  69. - kind: Server
  70. name: e2e-server
  71. ports:
  72. - type: rj45
  73. speed: 1
  74. count: 4
  75. - kind: Switch
  76. name: e2e-switch
  77. ports:
  78. - type: rj45
  79. speed: 1
  80. count: 8
  81. - kind: System
  82. name: e2e-host
  83. type: baremetal
  84. os: debian
  85. ip: 10.9.0.1
  86. runsOn:
  87. - e2e-server
  88. - kind: Service
  89. name: e2e-app
  90. runsOn:
  91. - e2e-host
  92. network:
  93. ip: 10.9.0.1
  94. port: 8080
  95. protocol: TCP
  96. """;
  97. JsonElement preview = await CallAsync("upsert_resources",
  98. new Dictionary<string, object?> { ["yaml"] = stack, ["dryRun"] = true });
  99. Assert.Equal(4, preview.GetProperty("added").GetArrayLength());
  100. JsonElement applied = await CallAsync("upsert_resources",
  101. new Dictionary<string, object?> { ["yaml"] = stack });
  102. Assert.Equal(4, applied.GetProperty("added").GetArrayLength());
  103. // 3. Query it back over the wire.
  104. JsonElement list = await CallAsync("list_resources",
  105. new Dictionary<string, object?> { ["kind"] = "Service" });
  106. Assert.Equal(1, list.GetProperty("count").GetInt32());
  107. JsonElement detail = await CallAsync("get_resource",
  108. new Dictionary<string, object?> { ["name"] = "e2e-app" });
  109. Assert.Contains("kind: Service", detail.GetProperty("yaml").GetString());
  110. JsonElement search = await CallAsync("search_resources",
  111. new Dictionary<string, object?> { ["query"] = "10.9.0.1" });
  112. Assert.True(search.GetProperty("matches").GetArrayLength() >= 1);
  113. // 4. Wire the hardware together and see it in the tree and the diagram.
  114. await CallAsync("add_connection", new Dictionary<string, object?> {
  115. ["resourceA"] = "e2e-server",
  116. ["portGroupA"] = 0,
  117. ["portIndexA"] = 0,
  118. ["resourceB"] = "e2e-switch",
  119. ["portGroupB"] = 0,
  120. ["portIndexB"] = 0,
  121. ["label"] = "uplink"
  122. });
  123. JsonElement tree = await CallAsync("get_tree",
  124. new Dictionary<string, object?> { ["hardwareName"] = "e2e-server" });
  125. Assert.Equal(1, tree.GetProperty("hardware").GetArrayLength());
  126. JsonElement mermaid = await CallAsync("export_topology_mermaid");
  127. Assert.Contains("e2e-switch", mermaid.GetProperty("text").GetString());
  128. // 5. The change shows up on the page a browser would load, not just over MCP.
  129. var home = await _http.GetStringAsync(
  130. $"http://127.0.0.1:{_container.GetMappedPublicPort(8080)}/health");
  131. Assert.Equal("rackpeek", home);
  132. // 6. Tear the service down again and the inventory agrees.
  133. await CallAsync("delete_resource", new Dictionary<string, object?> { ["name"] = "e2e-app" });
  134. JsonElement after = await CallAsync("list_resources",
  135. new Dictionary<string, object?> { ["kind"] = "Service" });
  136. Assert.Equal(0, after.GetProperty("count").GetInt32());
  137. }
  138. [Fact]
  139. public async Task The_gate_holds_over_a_real_network_socket() {
  140. using var bare = new HttpClient();
  141. var url = $"http://127.0.0.1:{_container.GetMappedPublicPort(8080)}/mcp";
  142. using var request = new HttpRequestMessage(HttpMethod.Post, url) {
  143. Content = new StringContent(
  144. """{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"t","version":"1"}}}""",
  145. System.Text.Encoding.UTF8,
  146. "application/json")
  147. };
  148. request.Headers.Add("Accept", "application/json, text/event-stream");
  149. using HttpResponseMessage response = await bare.SendAsync(request);
  150. Assert.Equal(System.Net.HttpStatusCode.Unauthorized, response.StatusCode);
  151. }
  152. }