| 1 | using Tomlyn; |
| 2 | using Tomlyn.Model; |
| 3 | |
| 4 | namespace AgentForge.Mcp; |
| 5 | |
| 6 | public static class ForgeMcpConfigLoader |
| 7 | { |
| 8 | public static ForgeMcpLoadResult Load(string[] args) |
| 9 | { |
| 10 | string? configPath = null; |
| 11 | for (var i = 0; i < args.Length; i++) |
| 12 | { |
| 13 | var arg = args[i]; |
| 14 | if (arg is "--config" or "-c") |
| 15 | { |
| 16 | if (i + 1 >= args.Length) |
| 17 | return ForgeMcpLoadResult.Fail("--config requires a file path."); |
| 18 | |
| 19 | configPath = args[++i]; |
| 20 | continue; |
| 21 | } |
| 22 | |
| 23 | if (arg.StartsWith("--config=", StringComparison.Ordinal)) |
| 24 | { |
| 25 | configPath = arg["--config=".Length..]; |
| 26 | continue; |
| 27 | } |
| 28 | |
| 29 | if (arg is "--help" or "-h") |
| 30 | return ForgeMcpLoadResult.Help(); |
| 31 | } |
| 32 | |
| 33 | ForgeMcpTomlValues? toml = null; |
| 34 | if (!string.IsNullOrWhiteSpace(configPath)) |
| 35 | { |
| 36 | var path = Path.GetFullPath(configPath.Trim()); |
| 37 | if (!File.Exists(path)) |
| 38 | return ForgeMcpLoadResult.Fail($"Config file not found: {path}"); |
| 39 | |
| 40 | try |
| 41 | { |
| 42 | toml = ForgeMcpTomlParser.Parse(File.ReadAllText(path)); |
| 43 | } |
| 44 | catch (Exception ex) |
| 45 | { |
| 46 | return ForgeMcpLoadResult.Fail($"Invalid TOML config '{path}': {ex.Message}"); |
| 47 | } |
| 48 | } |
| 49 | |
| 50 | var baseUrl = FirstNonEmpty( |
| 51 | Environment.GetEnvironmentVariable("FORGE_BASE_URL"), |
| 52 | toml?.BaseUrl, |
| 53 | ForgeMcpSettings.DefaultBaseUrl); |
| 54 | |
| 55 | if (!Uri.TryCreate(baseUrl, UriKind.Absolute, out var baseUri)) |
| 56 | return ForgeMcpLoadResult.Fail("forge base_url / FORGE_BASE_URL must be an absolute URL."); |
| 57 | |
| 58 | var apiToken = FirstNonEmpty( |
| 59 | Environment.GetEnvironmentVariable("FORGE_API_TOKEN"), |
| 60 | toml?.ApiToken); |
| 61 | |
| 62 | var actor = FirstNonEmpty( |
| 63 | Environment.GetEnvironmentVariable("FORGE_ACTOR"), |
| 64 | toml?.Actor); |
| 65 | |
| 66 | var toolsListChanged = ReadBoolEnv("FORGE_MCP_TOOLS_LIST_CHANGED") |
| 67 | ?? toml?.ToolsListChanged |
| 68 | ?? false; |
| 69 | |
| 70 | var pollSeconds = ReadIntEnv("FORGE_MCP_CAPABILITIES_POLL_SECONDS") |
| 71 | ?? toml?.CapabilitiesPollSeconds |
| 72 | ?? 0; |
| 73 | |
| 74 | var syncCursorTools = ReadBoolEnv("FORGE_CURSOR_SYNC_TOOLS") |
| 75 | ?? ReadBoolEnv("FORGE_MCP_SYNC_CURSOR_TOOLS") |
| 76 | ?? toml?.SyncCursorToolDescriptors |
| 77 | ?? false; |
| 78 | |
| 79 | var cursorToolsDir = FirstNonEmpty( |
| 80 | Environment.GetEnvironmentVariable("FORGE_CURSOR_TOOLS_DIR"), |
| 81 | toml?.CursorToolDescriptorsDir); |
| 82 | |
| 83 | return ForgeMcpLoadResult.Ok(new ForgeMcpSettings |
| 84 | { |
| 85 | BaseUri = baseUri, |
| 86 | ApiToken = apiToken, |
| 87 | Actor = actor, |
| 88 | ToolsListChanged = toolsListChanged, |
| 89 | CapabilitiesPollSeconds = pollSeconds, |
| 90 | SyncCursorToolDescriptors = syncCursorTools, |
| 91 | CursorToolDescriptorsDir = cursorToolsDir, |
| 92 | }); |
| 93 | } |
| 94 | |
| 95 | private static string? FirstNonEmpty(params string?[] values) |
| 96 | { |
| 97 | foreach (var value in values) |
| 98 | { |
| 99 | if (!string.IsNullOrWhiteSpace(value)) |
| 100 | return value.Trim(); |
| 101 | } |
| 102 | |
| 103 | return null; |
| 104 | } |
| 105 | |
| 106 | private static bool? ReadBoolEnv(string name) |
| 107 | { |
| 108 | var raw = Environment.GetEnvironmentVariable(name); |
| 109 | if (string.IsNullOrWhiteSpace(raw)) |
| 110 | return null; |
| 111 | |
| 112 | return raw.Trim() switch |
| 113 | { |
| 114 | "1" or "true" or "True" or "yes" or "YES" => true, |
| 115 | "0" or "false" or "False" or "no" or "NO" => false, |
| 116 | _ => null, |
| 117 | }; |
| 118 | } |
| 119 | |
| 120 | private static int? ReadIntEnv(string name) |
| 121 | { |
| 122 | var raw = Environment.GetEnvironmentVariable(name); |
| 123 | return int.TryParse(raw, out var value) ? value : null; |
| 124 | } |
| 125 | } |
| 126 | |
| 127 | public sealed class ForgeMcpLoadResult |
| 128 | { |
| 129 | public ForgeMcpSettings? Settings { get; init; } |
| 130 | |
| 131 | public string? Error { get; init; } |
| 132 | |
| 133 | public bool IsHelp { get; init; } |
| 134 | |
| 135 | public bool IsSuccess => Settings is not null && Error is null; |
| 136 | |
| 137 | public static ForgeMcpLoadResult Ok(ForgeMcpSettings settings) => new() { Settings = settings }; |
| 138 | |
| 139 | public static ForgeMcpLoadResult Fail(string error) => new() { Error = error }; |
| 140 | |
| 141 | public static ForgeMcpLoadResult Help() => new() { IsHelp = true }; |
| 142 | } |
| 143 | |
| 144 | internal sealed class ForgeMcpTomlValues |
| 145 | { |
| 146 | public string? BaseUrl { get; init; } |
| 147 | |
| 148 | public string? ApiToken { get; init; } |
| 149 | |
| 150 | public string? Actor { get; init; } |
| 151 | |
| 152 | public bool? ToolsListChanged { get; init; } |
| 153 | |
| 154 | public int? CapabilitiesPollSeconds { get; init; } |
| 155 | |
| 156 | public bool? SyncCursorToolDescriptors { get; init; } |
| 157 | |
| 158 | public string? CursorToolDescriptorsDir { get; init; } |
| 159 | } |
| 160 | |
| 161 | internal static class ForgeMcpTomlParser |
| 162 | { |
| 163 | internal const int SupportedVersion = 1; |
| 164 | |
| 165 | internal static ForgeMcpTomlValues Parse(string text) |
| 166 | { |
| 167 | var model = Toml.ToModel(text); |
| 168 | var version = ReadInt(model, "version"); |
| 169 | if (version is not null && version != SupportedVersion) |
| 170 | throw new InvalidOperationException($"Unsupported version {version}; expected {SupportedVersion}."); |
| 171 | |
| 172 | var forge = ReadTable(model, "forge"); |
| 173 | var identity = ReadTable(model, "identity"); |
| 174 | var mcp = ReadTable(model, "mcp"); |
| 175 | var cursor = ReadTable(model, "cursor"); |
| 176 | |
| 177 | return new ForgeMcpTomlValues |
| 178 | { |
| 179 | BaseUrl = ReadString(forge, "base_url"), |
| 180 | ApiToken = ReadString(identity, "api_token") ?? ReadString(forge, "api_token"), |
| 181 | Actor = ReadString(identity, "actor"), |
| 182 | ToolsListChanged = ReadBool(mcp, "tools_list_changed"), |
| 183 | CapabilitiesPollSeconds = ReadInt(mcp, "capabilities_poll_seconds"), |
| 184 | SyncCursorToolDescriptors = ReadBool(cursor, "sync_tool_descriptors"), |
| 185 | CursorToolDescriptorsDir = ReadString(cursor, "tool_descriptors_dir"), |
| 186 | }; |
| 187 | } |
| 188 | |
| 189 | private static TomlTable? ReadTable(TomlTable model, string key) => |
| 190 | model.TryGetValue(key, out var value) && value is TomlTable table ? table : null; |
| 191 | |
| 192 | private static string? ReadString(TomlTable? table, string key) |
| 193 | { |
| 194 | if (table is null || !table.TryGetValue(key, out var value) || value is null) |
| 195 | return null; |
| 196 | |
| 197 | return value switch |
| 198 | { |
| 199 | string s => s.Trim(), |
| 200 | _ => value.ToString()?.Trim(), |
| 201 | }; |
| 202 | } |
| 203 | |
| 204 | private static int? ReadInt(TomlTable? table, string key) |
| 205 | { |
| 206 | if (table is null || !table.TryGetValue(key, out var value) || value is null) |
| 207 | return null; |
| 208 | |
| 209 | return value switch |
| 210 | { |
| 211 | int i => i, |
| 212 | long l => (int)l, |
| 213 | string s when int.TryParse(s, out var parsed) => parsed, |
| 214 | _ => throw new InvalidOperationException($"Expected integer for '{key}'."), |
| 215 | }; |
| 216 | } |
| 217 | |
| 218 | private static bool? ReadBool(TomlTable? table, string key) |
| 219 | { |
| 220 | if (table is null || !table.TryGetValue(key, out var value) || value is null) |
| 221 | return null; |
| 222 | |
| 223 | return value switch |
| 224 | { |
| 225 | bool b => b, |
| 226 | string s when bool.TryParse(s, out var parsed) => parsed, |
| 227 | string s when s is "1" or "0" => s == "1", |
| 228 | _ => throw new InvalidOperationException($"Expected boolean for '{key}'."), |
| 229 | }; |
| 230 | } |
| 231 | } |
| 232 | |