McpFixture.cs 3.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102
  1. using Microsoft.AspNetCore.Mvc.Testing;
  2. using Microsoft.Extensions.Configuration;
  3. using ModelContextProtocol.Client;
  4. using RackPeek.Web;
  5. namespace Tests.Mcp;
  6. /// <summary>
  7. /// A real RackPeek server backed by a temporary config file, driven through a real
  8. /// MCP client speaking streamable-HTTP JSON-RPC to /mcp. Every test in this project
  9. /// goes end to end through this: if a behaviour is not observable here, an MCP
  10. /// client cannot observe it either.
  11. /// </summary>
  12. public sealed class McpFixture : IDisposable {
  13. public const string ApiKey = "mcp-test-key";
  14. private readonly WebApplicationFactory<Program> _factory;
  15. /// <param name="initialConfig">
  16. /// Contents to seed config.yaml with before the server first reads it.
  17. /// </param>
  18. /// <param name="extraConfig">
  19. /// Extra configuration for the server — e.g. GIT_TOKEN to activate the git
  20. /// tools, or RPK_API_KEY = null to model a server with no key configured.
  21. /// </param>
  22. public McpFixture(string? initialConfig = null, IDictionary<string, string?>? extraConfig = null) {
  23. TempDir = Path.Combine(Path.GetTempPath(), "rackpeek-mcp-tests", Guid.NewGuid().ToString());
  24. Directory.CreateDirectory(TempDir);
  25. if (initialConfig != null)
  26. File.WriteAllText(ConfigPath, initialConfig);
  27. _factory = new WebApplicationFactory<Program>()
  28. .WithWebHostBuilder(builder => {
  29. builder.UseSetting("RPK_YAML_DIR", TempDir);
  30. // Settings read during startup (GIT_TOKEN wires services in BuildApp)
  31. // must go through UseSetting: the in-memory collection below lands too
  32. // late for service registration, though request-time reads see it fine.
  33. foreach ((var key, var value) in extraConfig ?? new Dictionary<string, string?>())
  34. if (value != null)
  35. builder.UseSetting(key, value);
  36. builder.ConfigureAppConfiguration((_, config) => {
  37. var values = new Dictionary<string, string?> {
  38. ["RPK_YAML_DIR"] = TempDir,
  39. ["RPK_API_KEY"] = ApiKey
  40. };
  41. foreach ((var key, var value) in extraConfig ?? new Dictionary<string, string?>())
  42. values[key] = value;
  43. config.AddInMemoryCollection(values);
  44. });
  45. });
  46. }
  47. public string TempDir { get; }
  48. public string ConfigPath => Path.Combine(TempDir, "config.yaml");
  49. /// <summary>What is actually on disk — the ground truth every mutation test asserts on.</summary>
  50. public string StoredYaml => File.ReadAllText(ConfigPath);
  51. /// <summary>An HTTP client for driving /mcp (or anything else) below the MCP layer.</summary>
  52. public HttpClient CreateHttpClient(string? apiKey = ApiKey) {
  53. HttpClient client = _factory.CreateClient();
  54. if (apiKey != null)
  55. client.DefaultRequestHeaders.Add("X-Api-Key", apiKey);
  56. return client;
  57. }
  58. /// <summary>A connected MCP client; the initialize handshake has already succeeded.</summary>
  59. public async Task<McpClient> ConnectAsync(string? apiKey = ApiKey) {
  60. HttpClient http = CreateHttpClient(apiKey);
  61. var transport = new HttpClientTransport(
  62. new HttpClientTransportOptions {
  63. Endpoint = new Uri(http.BaseAddress!, "mcp"),
  64. TransportMode = HttpTransportMode.StreamableHttp
  65. },
  66. http,
  67. loggerFactory: null,
  68. ownsHttpClient: true);
  69. return await McpClient.CreateAsync(transport);
  70. }
  71. public void Dispose() {
  72. try {
  73. _factory.Dispose();
  74. if (Directory.Exists(TempDir))
  75. Directory.Delete(TempDir, true);
  76. }
  77. catch {
  78. // Cleanup only; a leftover temp directory must never fail a test run.
  79. }
  80. }
  81. }