|
| 1 | +// Copyright (c) Erik Darling Data. All rights reserved. |
| 2 | +// Licensed under the terms in the LICENSE file in the repository root. |
| 3 | + |
| 4 | +using System; |
| 5 | +using System.Collections.Generic; |
| 6 | +using System.IO; |
| 7 | +using System.Linq; |
| 8 | +using System.Reflection; |
| 9 | +using System.Text.RegularExpressions; |
| 10 | +using ModelContextProtocol.Server; |
| 11 | +using PerformanceMonitor.Darling.Service.Mcp; |
| 12 | +using Xunit; |
| 13 | + |
| 14 | +namespace Darling.Tests; |
| 15 | + |
| 16 | +/// <summary> |
| 17 | +/// Every <see cref="McpServerToolTypeAttribute"/> class must actually be registered with the MCP host |
| 18 | +/// (#2659). |
| 19 | +/// |
| 20 | +/// <para><b>Six were not.</b> Ten shipped PostgreSQL reads — <c>get_pg_write_stats</c>, |
| 21 | +/// <c>get_pg_buffer_usage</c>, <c>get_pg_extensions</c>, <c>get_pg_lock_stats</c>, |
| 22 | +/// <c>get_pg_index_bloat</c>, <c>get_pg_column_stats</c>, <c>get_pg_kernel_stats</c>, |
| 23 | +/// <c>get_pg_predicate_stats</c>, <c>get_pg_replication_stats</c>, <c>get_pg_wait_sampling</c> — were |
| 24 | +/// implemented, documented, dispatched by the web API, counted in the instructions census and covered by |
| 25 | +/// the name-based inventory pin, and an agent could not call any of them. Asked of the running service, |
| 26 | +/// <c>tools/list</c> answered 116 tools where the census claimed 126.</para> |
| 27 | +/// |
| 28 | +/// <para><b>Why the existing guards could not see it.</b> The inventory pin checks tool NAMES, and the |
| 29 | +/// names exist — the attribute is on the method whether or not the class is registered. The |
| 30 | +/// <c>POSTGRES_TABS</c> pin asserts every <c>get_pg_*</c> read reaches a web tab, and its own header says |
| 31 | +/// it exists so a new read "cannot ship reachable only through MCP". This is the exact inverse, and there |
| 32 | +/// was no pin for it: these shipped reachable only through the WEB.</para> |
| 33 | +/// |
| 34 | +/// <para><b>Derived, not enumerated.</b> The check walks the assembly for the attribute and the host source |
| 35 | +/// for its registrations, so it cannot go stale the way a hand-kept list does — and it fails the moment |
| 36 | +/// someone adds a class, rather than whenever an agent next reaches for the tool. That is the same |
| 37 | +/// reasoning as the tab pin being derived from the dispatch.</para> |
| 38 | +/// </summary> |
| 39 | +public sealed class McpToolTypeRegistrationTests |
| 40 | +{ |
| 41 | + [Fact] |
| 42 | + public void EveryMcpServerToolTypeClass_IsRegisteredWithTheHost() |
| 43 | + { |
| 44 | + var declared = typeof(DarlingMcpHostService).Assembly |
| 45 | + .GetTypes() |
| 46 | + .Where(t => t.GetCustomAttribute<McpServerToolTypeAttribute>() is not null) |
| 47 | + .Select(t => t.Name) |
| 48 | + .OrderBy(n => n, StringComparer.Ordinal) |
| 49 | + .ToList(); |
| 50 | + |
| 51 | + Assert.NotEmpty(declared); |
| 52 | + |
| 53 | + var registered = RegisteredToolTypeNames(); |
| 54 | + |
| 55 | + var missing = declared.Where(n => !registered.Contains(n)).ToList(); |
| 56 | + |
| 57 | + Assert.True( |
| 58 | + missing.Count == 0, |
| 59 | + "These [McpServerToolType] classes are never registered with the MCP host, so every tool they " |
| 60 | + + "declare is unreachable over MCP even though its name exists and the web API dispatches it: " |
| 61 | + + string.Join(", ", missing) |
| 62 | + + ". Add a .WithGeminiCompatibleTools<T>() line in DarlingMcpHostService."); |
| 63 | + } |
| 64 | + |
| 65 | + /// <summary> |
| 66 | + /// Reads the registrations out of the host SOURCE rather than by invoking the builder, because the |
| 67 | + /// builder needs a host, a store and a live configuration, and this is a wiring question that should be |
| 68 | + /// answerable without any of them. |
| 69 | + /// </summary> |
| 70 | + private static HashSet<string> RegisteredToolTypeNames() |
| 71 | + { |
| 72 | + var path = HostSourcePath(); |
| 73 | + var source = File.ReadAllText(path); |
| 74 | + |
| 75 | + var names = Regex |
| 76 | + .Matches(source, @"WithGeminiCompatibleTools<(\w+)>") |
| 77 | + .Select(m => m.Groups[1].Value) |
| 78 | + .ToHashSet(StringComparer.Ordinal); |
| 79 | + |
| 80 | + Assert.True( |
| 81 | + names.Count > 0, |
| 82 | + $"Found no .WithGeminiCompatibleTools<T>() registrations in {path}. If the registration style " |
| 83 | + + "changed, this test needs to learn the new one rather than be deleted — it is the only thing " |
| 84 | + + "standing between a new tools class and shipping unreachable."); |
| 85 | + |
| 86 | + return names; |
| 87 | + } |
| 88 | + |
| 89 | + private static string HostSourcePath() |
| 90 | + { |
| 91 | + var dir = new DirectoryInfo(AppContext.BaseDirectory); |
| 92 | + |
| 93 | + while (dir is not null) |
| 94 | + { |
| 95 | + var candidate = Path.Combine( |
| 96 | + dir.FullName, |
| 97 | + "Darling", |
| 98 | + "PerformanceMonitor.Darling.Service", |
| 99 | + "Mcp", |
| 100 | + "DarlingMcpHostService.cs"); |
| 101 | + |
| 102 | + if (File.Exists(candidate)) |
| 103 | + { |
| 104 | + return candidate; |
| 105 | + } |
| 106 | + |
| 107 | + dir = dir.Parent; |
| 108 | + } |
| 109 | + |
| 110 | + throw new FileNotFoundException( |
| 111 | + "Could not locate DarlingMcpHostService.cs by walking up from the test output directory."); |
| 112 | + } |
| 113 | +} |
0 commit comments