|
| 1 | +using Microsoft.Extensions.DependencyInjection; |
| 2 | +using Microsoft.Extensions.Hosting; |
| 3 | +using Microsoft.Extensions.Logging; |
| 4 | +using ModelContextProtocol; |
| 5 | +using ModelContextProtocol.Protocol; |
| 6 | +using ModelContextProtocol.Server; |
| 7 | +using System.Reflection; |
| 8 | +using System.Text.Json; |
| 9 | +using TALXIS.CLI; |
| 10 | + |
| 11 | +var builder = new HostApplicationBuilder(args); |
| 12 | +builder.Logging.AddConsole(consoleLogOptions => |
| 13 | +{ |
| 14 | + // Configure all logs to go to stderr |
| 15 | + consoleLogOptions.LogToStandardErrorThreshold = LogLevel.Trace; |
| 16 | +}); |
| 17 | + |
| 18 | +builder.Services.AddMcpServer(options => |
| 19 | +{ |
| 20 | + var version = typeof(Program).Assembly.GetName().Version?.ToString() ?? "unknown"; |
| 21 | + options.ServerInfo = new Implementation |
| 22 | + { |
| 23 | + Name = "TALXIS CLI MCP", |
| 24 | + Version = version |
| 25 | + }; |
| 26 | + options.ServerInstructions = "This server is a wrapper for the TALXIS CLI. It allows MCP clients to execute the same commands through this server as if they were running the CLI in their terminal."; |
| 27 | + options.Capabilities = new ServerCapabilities |
| 28 | + { |
| 29 | + Tools = new ToolsCapability |
| 30 | + { |
| 31 | + ListChanged = true, |
| 32 | + ListToolsHandler = ListToolsAsync, |
| 33 | + CallToolHandler = CallToolAsync |
| 34 | + } |
| 35 | + }; |
| 36 | +}) |
| 37 | +.WithStdioServerTransport(); |
| 38 | + |
| 39 | + |
| 40 | + |
| 41 | +await builder.Build().RunAsync(); |
| 42 | + |
| 43 | +// Dynamically list all CLI commands and subcommands as MCP tools |
| 44 | +static ValueTask<ListToolsResult> ListToolsAsync(RequestContext<ListToolsRequestParams> ctx, CancellationToken ct) |
| 45 | +{ |
| 46 | + var toolDefs = new List<Tool>(); |
| 47 | + var rootType = typeof(TxcCliCommand); |
| 48 | + var rootCommands = new[] { rootType }; |
| 49 | + foreach (var cmdType in rootCommands) |
| 50 | + { |
| 51 | + AddCommandAndChildren(cmdType, toolDefs, parentName: null, rootType: rootType); |
| 52 | + } |
| 53 | + return ValueTask.FromResult(new ListToolsResult { Tools = toolDefs }); |
| 54 | +} |
| 55 | + |
| 56 | +static void AddCommandAndChildren(Type cmdType, List<Tool> defs, string? parentName, Type rootType) |
| 57 | +{ |
| 58 | + var attr = cmdType.GetCustomAttribute(typeof(DotMake.CommandLine.CliCommandAttribute)) as DotMake.CommandLine.CliCommandAttribute; |
| 59 | + if (attr == null) return; |
| 60 | + var name = attr.Name ?? cmdType.Name.Replace("CliCommand", "").ToLowerInvariant(); |
| 61 | + bool isRoot = cmdType == rootType; |
| 62 | + bool isDirectChildOfRoot = parentName == null && isRoot == false; |
| 63 | + bool isGroup = attr.Children != null && attr.Children.Length > 0; |
| 64 | + // If parent is root, don't include its name in the tool name |
| 65 | + var fullName = (parentName == null || parentName == (rootType.GetCustomAttribute(typeof(DotMake.CommandLine.CliCommandAttribute)) as DotMake.CommandLine.CliCommandAttribute)?.Name || parentName == rootType.Name.Replace("CliCommand", "").ToLowerInvariant()) |
| 66 | + ? name : $"{parentName}-{name}"; |
| 67 | + // Only register as a tool if it's not the root, not a direct child of root, and not a group |
| 68 | + if (!isGroup && !isRoot && !isDirectChildOfRoot) |
| 69 | + { |
| 70 | + var tool = new Tool |
| 71 | + { |
| 72 | + Name = fullName, |
| 73 | + Description = attr.Description, |
| 74 | + InputSchema = BuildInputSchema(cmdType) |
| 75 | + }; |
| 76 | + defs.Add(tool); |
| 77 | + } |
| 78 | + if (attr.Children != null) |
| 79 | + { |
| 80 | + foreach (var child in attr.Children) |
| 81 | + AddCommandAndChildren(child, defs, fullName, rootType); |
| 82 | + } |
| 83 | +} |
| 84 | + |
| 85 | +static JsonElement BuildInputSchema(Type cmdType) |
| 86 | +{ |
| 87 | + var props = cmdType.GetProperties(BindingFlags.Public | BindingFlags.Instance) |
| 88 | + .Select(p => (p, attr: p.GetCustomAttribute(typeof(DotMake.CommandLine.CliOptionAttribute)) as DotMake.CommandLine.CliOptionAttribute)) |
| 89 | + .Where(x => x.attr != null) |
| 90 | + .ToList(); |
| 91 | + var required = props.Where(x => x.attr != null && x.attr.Required) |
| 92 | + .Select(x => (x.attr!.Name ?? x.p.Name).TrimStart('-')).ToList(); |
| 93 | + var properties = new Dictionary<string, object?>(); |
| 94 | + foreach (var (p, attr) in props) |
| 95 | + { |
| 96 | + if (attr == null) continue; |
| 97 | + var type = p.PropertyType == typeof(bool) ? "boolean" : "string"; |
| 98 | + var optionName = (attr.Name ?? p.Name).TrimStart('-'); |
| 99 | + properties[optionName] = new Dictionary<string, object?> |
| 100 | + { |
| 101 | + ["type"] = type, |
| 102 | + ["description"] = attr.Description |
| 103 | + }; |
| 104 | + } |
| 105 | + var schema = new Dictionary<string, object?> |
| 106 | + { |
| 107 | + ["type"] = "object", |
| 108 | + ["properties"] = properties, |
| 109 | + ["required"] = required |
| 110 | + }; |
| 111 | + return JsonSerializer.Deserialize<JsonElement>(JsonSerializer.Serialize(schema)); |
| 112 | +} |
| 113 | + |
| 114 | +// Call a CLI command by reconstructing args and running it |
| 115 | +static async ValueTask<CallToolResult> CallToolAsync(RequestContext<CallToolRequestParams> ctx, CancellationToken ct) |
| 116 | +{ |
| 117 | + var p = ctx.Params; |
| 118 | + var toolName = p?.Name ?? string.Empty; |
| 119 | + if (string.IsNullOrEmpty(toolName)) |
| 120 | + throw new McpException("Tool name is required."); |
| 121 | + // Always start traversal from TxcCliCommand |
| 122 | + Type? cmdType = FindCommandTypeByName(toolName, typeof(TxcCliCommand)); |
| 123 | + if (cmdType == null) |
| 124 | + throw new McpException($"Tool '{toolName}' not found."); |
| 125 | + |
| 126 | + // Build args from params, supporting subcommands (e.g. "data server") |
| 127 | + var cliArgs = toolName.Split('-', StringSplitOptions.RemoveEmptyEntries).ToList(); |
| 128 | + if (p != null && p.Arguments is not null) |
| 129 | + { |
| 130 | + foreach (var entry in p.Arguments) |
| 131 | + { |
| 132 | + var k = entry.Key; |
| 133 | + var v = entry.Value; |
| 134 | + if (v.ValueKind != JsonValueKind.Null) |
| 135 | + cliArgs.Add($"--{k}={v}"); |
| 136 | + } |
| 137 | + } |
| 138 | + |
| 139 | + // Run the CLI command and capture output |
| 140 | + var output = new StringWriter(); |
| 141 | + var origOut = Console.Out; |
| 142 | + Console.SetOut(output); |
| 143 | + try |
| 144 | + { |
| 145 | + // Use the CLI entry point |
| 146 | + await TALXIS.CLI.Program.RunCli(cliArgs.ToArray()); |
| 147 | + } |
| 148 | + catch (Exception ex) |
| 149 | + { |
| 150 | + return new CallToolResult { Content = [new TextContentBlock { Text = ex.ToString(), Type = "text" }] }; |
| 151 | + } |
| 152 | + finally |
| 153 | + { |
| 154 | + Console.SetOut(origOut); |
| 155 | + } |
| 156 | + return new CallToolResult { Content = [new TextContentBlock { Text = output.ToString(), Type = "text" }] }; |
| 157 | +} |
| 158 | + |
| 159 | + |
| 160 | +static Type? FindCommandTypeByName(string toolName, Type root) |
| 161 | +{ |
| 162 | + var segments = toolName.Split('-', StringSplitOptions.RemoveEmptyEntries); |
| 163 | + // Try matching as-is (without root segment) |
| 164 | + var found = FindCommandTypeBySegments(segments, 0, root); |
| 165 | + if (found != null) |
| 166 | + return found; |
| 167 | + // Try matching with root segment prepended |
| 168 | + var attr = root.GetCustomAttribute(typeof(DotMake.CommandLine.CliCommandAttribute)) as DotMake.CommandLine.CliCommandAttribute; |
| 169 | + var rootName = attr?.Name ?? root.Name.Replace("CliCommand", "").ToLowerInvariant(); |
| 170 | + var withRoot = new string[] { rootName }.Concat(segments).ToArray(); |
| 171 | + return FindCommandTypeBySegments(withRoot, 0, root); |
| 172 | +} |
| 173 | + |
| 174 | +static Type? FindCommandTypeBySegments(string[] segments, int index, Type type) |
| 175 | +{ |
| 176 | + var attr = type.GetCustomAttribute(typeof(DotMake.CommandLine.CliCommandAttribute)) as DotMake.CommandLine.CliCommandAttribute; |
| 177 | + var cmdName = attr?.Name ?? type.Name.Replace("CliCommand", "").ToLowerInvariant(); |
| 178 | + if (!string.Equals(cmdName, segments[index], StringComparison.OrdinalIgnoreCase)) |
| 179 | + return null; |
| 180 | + if (index == segments.Length - 1) |
| 181 | + return type; |
| 182 | + if (attr?.Children != null) |
| 183 | + { |
| 184 | + foreach (var child in attr.Children) |
| 185 | + { |
| 186 | + var found = FindCommandTypeBySegments(segments, index + 1, child); |
| 187 | + if (found != null) return found; |
| 188 | + } |
| 189 | + } |
| 190 | + return null; |
| 191 | +} |
0 commit comments