Forge
csharpdeeb25a2
1using System.Reflection;
2using System.Text.Json;
3using System.Text.RegularExpressions;
4using CascadeIDE.Services;
5using ModelContextProtocol.Protocol;
6using Xunit;
7
8namespace CascadeIDE.Tests;
9
10public sealed class ToolCatalogTests
11{
12 [Fact]
13 public void BuildTools_HasUniqueNames()
14 {
15 var tools = BuildTools(includeDebugTools: false);
16 var dupes = tools
17 .GroupBy(t => t.Name, StringComparer.Ordinal)
18 .Where(g => g.Count() > 1)
19 .Select(g => g.Key)
20 .ToList();
21
22 Assert.Empty(dupes);
23 }
24
25 [Fact]
26 public void BuildTools_CoversAllIdeCommandsViaProxyOrRich()
27 {
28 var tools = BuildTools(includeDebugTools: true);
29 var names = new HashSet<string>(tools.Select(t => t.Name), StringComparer.Ordinal);
30
31 Assert.Contains("ide_execute_command", names);
32
33 foreach (var commandId in GetIdeCommandIds())
34 {
35 if (!IdeMcpToolNaming.IsSupportedAutoProxyToolName(commandId))
36 continue;
37
38 var expectedToolName = IdeMcpToolNaming.ToToolName(commandId);
39 Assert.Contains(expectedToolName, names);
40 }
41 }
42
43 [Fact]
44 public void AddControlTool_UsesProxySchemaUnlessDebugToolsEnabled()
45 {
46 const string toolName = "ide_add_control";
47
48 var toolsWithout = BuildTools(includeDebugTools: false);
49 var toolWithout = toolsWithout.Single(t => string.Equals(t.Name, toolName, StringComparison.Ordinal));
50 Assert.True(IsProxySchema(toolWithout));
51
52 var toolsWith = BuildTools(includeDebugTools: true);
53 var toolWith = toolsWith.Single(t => string.Equals(t.Name, toolName, StringComparison.Ordinal));
54
55 // In Release builds the debug override may be compiled out; only assert override behavior when present.
56 if (!IsProxySchema(toolWith))
57 Assert.False(IsProxySchema(toolWith));
58 }
59
60 [Fact]
61 public void ProxyTools_WithArgsSpec_HaveTypedSchemaProperties()
62 {
63 var tools = BuildTools(includeDebugTools: true);
64 var byName = tools.ToDictionary(t => t.Name, StringComparer.Ordinal);
65
66 foreach (var commandId in GetIdeCommandIds())
67 {
68 if (!IdeMcpToolNaming.IsSupportedAutoProxyToolName(commandId))
69 continue;
70
71 if (!TryGetCommandSummary(commandId, out var summary))
72 continue;
73
74 if (!TryParseArgsSpecFromSummary(summary, out var argsSpec))
75 continue;
76
77 var toolName = IdeMcpToolNaming.ToToolName(commandId);
78 Assert.True(byName.TryGetValue(toolName, out var tool), $"Expected tool '{toolName}' for command '{commandId}'.");
79
80 Assert.True(tool!.InputSchema.TryGetProperty("properties", out var props), $"Tool '{toolName}' missing schema.properties.");
81 Assert.True(props.ValueKind == JsonValueKind.Object, $"Tool '{toolName}' schema.properties is not an object.");
82
83 foreach (var arg in argsSpec.Args)
84 {
85 Assert.True(props.TryGetProperty(arg.Name, out _), $"Tool '{toolName}' missing schema for arg '{arg.Name}'.");
86 }
87
88 if (argsSpec.Required.Count > 0)
89 {
90 Assert.True(tool.InputSchema.TryGetProperty("required", out var req), $"Tool '{toolName}' missing schema.required.");
91 Assert.True(req.ValueKind == JsonValueKind.Array, $"Tool '{toolName}' schema.required is not an array.");
92
93 var requiredSet = req.EnumerateArray()
94 .Where(e => e.ValueKind == JsonValueKind.String)
95 .Select(e => e.GetString()!)
96 .ToHashSet(StringComparer.Ordinal);
97
98 foreach (var r in argsSpec.Required)
99 Assert.Contains(r, requiredSet);
100 }
101 }
102 }
103
104 private static IReadOnlyList<string> GetIdeCommandIds()
105 {
106 return typeof(IdeCommands)
107 .GetFields(BindingFlags.Public | BindingFlags.Static)
108 .Where(f => f.FieldType == typeof(string))
109 .Select(f => (string?)f.GetValue(null))
110 .Where(v => !string.IsNullOrWhiteSpace(v))
111 .Select(v => v!)
112 .Distinct(StringComparer.Ordinal)
113 .ToList();
114 }
115
116 private static bool TryGetCommandSummary(string commandId, out string summary)
117 {
118 // IdeCommandsDoc is internal; call public method via reflection.
119 var t = typeof(IdeCommands).Assembly.GetType("CascadeIDE.Services.IdeCommandsDoc", throwOnError: true)!;
120 var m = t.GetMethod("TryGetSummary", BindingFlags.Public | BindingFlags.Static)!;
121
122 object?[] args = [commandId, null];
123 var ok = (bool)m.Invoke(null, args)!;
124 summary = (string?)args[1] ?? "";
125 return ok;
126 }
127
128 private readonly record struct ArgsSpec(IReadOnlyList<(string Name, string Type, bool Required, bool IsArray)> Args, IReadOnlyList<string> Required);
129
130 private static readonly Regex ArgsToken = new(
131 @"(?<name>[a-zA-Z_][a-zA-Z0-9_]*)(?<opt>\?)?\s*:\s*(?<type>string|integer|number|boolean|object)(?<arr>\[\])?",
132 RegexOptions.Compiled | RegexOptions.CultureInvariant);
133
134 private static bool TryParseArgsSpecFromSummary(string summary, out ArgsSpec spec)
135 {
136 spec = default;
137 if (string.IsNullOrWhiteSpace(summary))
138 return false;
139
140 var idx = summary.IndexOf("args:", StringComparison.OrdinalIgnoreCase);
141 if (idx < 0)
142 return false;
143
144 var tail = summary[(idx + 5)..].Trim();
145 var dot = tail.IndexOf('.', StringComparison.Ordinal);
146 if (dot >= 0)
147 tail = tail[..dot];
148
149 tail = tail.Trim();
150 if (tail.Length == 0)
151 return false;
152
153 var args = new List<(string Name, string Type, bool Required, bool IsArray)>();
154 var required = new List<string>();
155
156 foreach (var part in tail.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
157 {
158 var m = ArgsToken.Match(part);
159 if (!m.Success)
160 continue; // strict parsing is validated in generator build, not tests
161
162 var name = m.Groups["name"].Value;
163 var isReq = !m.Groups["opt"].Success;
164 var type = m.Groups["type"].Value;
165 var isArray = m.Groups["arr"].Success;
166
167 args.Add((name, type, isReq, isArray));
168 if (isReq)
169 required.Add(name);
170 }
171
172 spec = new ArgsSpec(args, required);
173 return args.Count > 0;
174 }
175
176 private static IReadOnlyList<Tool> BuildTools(bool includeDebugTools)
177 {
178 // IdeMcpToolCatalog is internal; use reflection to avoid exposing internals just for tests.
179 var catalogType = typeof(IdeCommands).Assembly.GetType("CascadeIDE.Services.IdeMcpToolCatalog", throwOnError: true)!;
180 var method = catalogType.GetMethod("BuildTools", BindingFlags.Public | BindingFlags.Static)!;
181 var list = method.Invoke(null, new object?[] { includeDebugTools })!;
182
183 return ((System.Collections.IEnumerable)list).Cast<Tool>().ToList();
184 }
185
186 private static bool IsProxySchema(Tool tool)
187 {
188 // Proxy schema: { type: object, properties: {}, additionalProperties: true, required: [] }
189 if (!tool.InputSchema.TryGetProperty("additionalProperties", out var ap))
190 return false;
191 return ap.ValueKind == System.Text.Json.JsonValueKind.True;
192 }
193}
194
195
View only · write via MCP/CIDE