feat(mcp): extension project and configuration

McpOptions/McpServerConfig/PlaywrightOptions with ApplyEnvironment (PLAYWRIGHT_* variables, quote-aware ExtraArgs split) and Validate; ShellWords; mcpServers from the user settings.json. New project in the solution and referenced by the extension tests.
This commit is contained in:
EugeneTes
2026-09-26 20:13:50 +02:00
parent aaca3ad15a
commit e6b09fef1e
8 changed files with 433 additions and 0 deletions

View File

@@ -0,0 +1,113 @@
using System.Text.RegularExpressions;
using Microsoft.Extensions.Logging;
namespace Volcanic.AgenticCore.Extensions.Mcp;
/// <summary>MCP client configuration: the <c>Mcp</c> section of the host configuration, plus the <c>PLAYWRIGHT_*</c> variables.</summary>
public sealed class McpOptions
{
public const string SectionName = "Mcp";
private readonly List<string> _environmentErrors = [];
/// <summary>Servers by name. Enumeration order is the configured order; the first server wins a tool name.</summary>
public Dictionary<string, McpServerConfig> Servers { get; set; } = new(StringComparer.Ordinal);
/// <summary>The Playwright preset. Expands into a server named <c>playwright</c> when enabled.</summary>
public PlaywrightOptions Playwright { get; set; } = new();
/// <summary>
/// Fills unset preset values from <c>PLAYWRIGHT_ENABLED</c>, <c>PLAYWRIGHT_HEADLESS</c>, <c>PLAYWRIGHT_BROWSER</c>,
/// <c>PLAYWRIGHT_EXTRA_ARGS</c> (shell-style split) and <c>PLAYWRIGHT_COMMAND</c>. Configured values win; an empty
/// variable counts as unset. Problems are reported by <see cref="Validate"/>.
/// </summary>
public void ApplyEnvironment(Func<string, string?> getEnv)
{
ArgumentNullException.ThrowIfNull(getEnv);
string? Get(string name) => getEnv(name) is { Length: > 0 } value ? value : null;
var p = Playwright;
if (p.Enabled is null && Get("PLAYWRIGHT_ENABLED") is { } enabled) p.Enabled = ParseBool("PLAYWRIGHT_ENABLED", enabled);
if (p.Headless is null && Get("PLAYWRIGHT_HEADLESS") is { } headless) p.Headless = ParseBool("PLAYWRIGHT_HEADLESS", headless);
p.Browser ??= Get("PLAYWRIGHT_BROWSER");
p.Command ??= Get("PLAYWRIGHT_COMMAND");
if (p.ExtraArgs is not { Count: > 0 } && Get("PLAYWRIGHT_EXTRA_ARGS") is { } extra)
{
try { p.ExtraArgs = [.. ShellWords.Split(extra)]; }
catch (FormatException ex) { _environmentErrors.Add($"PLAYWRIGHT_EXTRA_ARGS: {ex.Message}."); }
}
}
private bool? ParseBool(string name, string value)
{
if (bool.TryParse(value, out var parsed)) return parsed;
if (value == "1") return true;
if (value == "0") return false;
_environmentErrors.Add($"{name} must be true or false, got '{value}'.");
return null;
}
/// <summary>Configuration errors: environment parsing problems and every server's own errors.</summary>
public IReadOnlyList<string> Validate()
{
var errors = new List<string>(_environmentErrors);
foreach (var (name, server) in Servers) errors.AddRange(server.Validate(name));
return errors;
}
}
/// <summary>One stdio MCP server. <c>${workspace}</c> expands in command, args, cwd and env; <c>${env:NAME}</c> in env values.</summary>
public sealed partial class McpServerConfig
{
public string Command { get; set; } = "";
public List<string> Args { get; set; } = [];
/// <summary>Applied over the filtered environment and never filtered. A null value removes the variable.</summary>
public Dictionary<string, string?> Env { get; set; } = new(StringComparer.Ordinal);
/// <summary>Working directory of the session process. Defaults to <c>${workspace}</c>.</summary>
public string? Cwd { get; set; }
public string? ToolPrefix { get; set; }
public bool Enabled { get; set; } = true;
public bool Parallel { get; set; }
public int StartupTimeoutSeconds { get; set; } = 60;
public int CallTimeoutSeconds { get; set; } = 120;
/// <summary>0 means an idle process is never stopped.</summary>
public int IdleTimeoutSeconds { get; set; } = 1800;
public LogLevel StderrLevel { get; set; } = LogLevel.Warning;
/// <summary>Added to the system prompt while any of this server's tools is active.</summary>
public string? PromptSection { get; set; }
public IReadOnlyList<string> Validate(string name)
{
var where = $"Mcp:Servers:{name}";
var errors = new List<string>();
if (string.IsNullOrWhiteSpace(name)) errors.Add("Mcp:Servers: a server name is empty.");
if (string.IsNullOrWhiteSpace(Command)) errors.Add($"{where}: command is required.");
if (StartupTimeoutSeconds <= 0) errors.Add($"{where}: startupTimeoutSeconds must be positive.");
if (CallTimeoutSeconds <= 0) errors.Add($"{where}: callTimeoutSeconds must be positive.");
if (IdleTimeoutSeconds < 0) errors.Add($"{where}: idleTimeoutSeconds must be 0 (never) or positive.");
if (ToolPrefix is { Length: > 0 } prefix)
{
if (!PrefixPattern().IsMatch(prefix)) errors.Add($"{where}: toolPrefix may only contain letters, digits, '_' and '-'.");
else if (prefix.StartsWith("mcp__", StringComparison.OrdinalIgnoreCase)) errors.Add($"{where}: toolPrefix must not start with mcp__.");
}
return errors;
}
[GeneratedRegex("^[A-Za-z0-9_-]*$")]
private static partial Regex PrefixPattern();
}
/// <summary>The Playwright preset. Null means "not configured" so the environment can fill it in.</summary>
public sealed class PlaywrightOptions
{
public bool? Enabled { get; set; }
public bool? Headless { get; set; }
public string? Browser { get; set; }
public List<string>? ExtraArgs { get; set; }
/// <summary>npm package spec used with npx. Defaults to <c>PlaywrightPreset.DefaultPackage</c>.</summary>
public string? Package { get; set; }
/// <summary>Run this binary instead of <c>npx -y &lt;package&gt;</c> (e.g. the globally installed <c>playwright-mcp</c>).</summary>
public string? Command { get; set; }
public bool IsEnabled => Enabled ?? false;
public bool IsHeadless => Headless ?? true;
}

View File

@@ -0,0 +1,49 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
namespace Volcanic.AgenticCore.Extensions.Mcp;
/// <summary>Reads <c>"mcpServers"</c> from <c>&lt;agent dir&gt;/settings.json</c> (the same file that lists extensions).</summary>
internal static class McpUserSettings
{
public const string AgentDirVariable = "VOLCANIC_AGENT_DIR";
private static readonly JsonSerializerOptions Json = new()
{
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
Converters = { new JsonStringEnumConverter() },
};
/// <summary><c>VOLCANIC_AGENT_DIR</c>, else <c>~/.volcanic/agent</c>.</summary>
public static string AgentDir(Func<string, string?> getEnv) =>
getEnv(AgentDirVariable) is { Length: > 0 } dir
? dir
: Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".volcanic", "agent");
/// <summary>Servers in document order. A missing file or section is empty; a malformed file is logged and ignored.</summary>
public static IReadOnlyList<KeyValuePair<string, McpServerConfig>> Load(string agentDir, ILogger logger)
{
var path = Path.Combine(agentDir, "settings.json");
if (!File.Exists(path)) return [];
try
{
using var doc = JsonDocument.Parse(File.ReadAllText(path),
new JsonDocumentOptions { CommentHandling = JsonCommentHandling.Skip, AllowTrailingCommas = true });
if (doc.RootElement.ValueKind != JsonValueKind.Object
|| !doc.RootElement.TryGetProperty("mcpServers", out var servers)
|| servers.ValueKind != JsonValueKind.Object) return [];
var result = new List<KeyValuePair<string, McpServerConfig>>();
foreach (var server in servers.EnumerateObject())
result.Add(new(server.Name, server.Value.Deserialize<McpServerConfig>(Json) ?? new McpServerConfig()));
return result;
}
catch (Exception ex) when (ex is JsonException or IOException or UnauthorizedAccessException or NotSupportedException)
{
logger.LogWarning("Ignoring mcpServers in {Path}: {Message}", path, ex.Message);
return [];
}
}
}

View File

@@ -0,0 +1,43 @@
using System.Text;
namespace Volcanic.AgenticCore.Extensions.Mcp;
/// <summary>Splits a command-line fragment into argv the way a POSIX shell would, without expansion.</summary>
internal static class ShellWords
{
/// <summary>Whitespace separates words; '…' is literal; "…" allows \" and \\. Backslashes elsewhere are kept (Windows paths).</summary>
public static IReadOnlyList<string> Split(string text)
{
var words = new List<string>();
var current = new StringBuilder();
var inWord = false;
char? quote = null;
for (var i = 0; i < text.Length; i++)
{
var c = text[i];
if (quote == '\'')
{
if (c == '\'') quote = null; else current.Append(c);
continue;
}
if (quote == '"')
{
if (c == '"') quote = null;
else if (c == '\\' && i + 1 < text.Length && text[i + 1] is '"' or '\\') current.Append(text[++i]);
else current.Append(c);
continue;
}
if (char.IsWhiteSpace(c))
{
if (inWord) { words.Add(current.ToString()); current.Clear(); inWord = false; }
continue;
}
inWord = true;
if (c is '"' or '\'') quote = c;
else current.Append(c);
}
if (quote is { } open) throw new FormatException($"unterminated {open} quote");
if (inWord) words.Add(current.ToString());
return words;
}
}

View File

@@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<Description>MCP client extension: stdio MCP servers as agent tools, with a Playwright browser preset.</Description>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../src/Volcanic.AgenticCore.Abstractions/Volcanic.AgenticCore.Abstractions.csproj" />
<PackageReference Include="ModelContextProtocol.Core" Version="2.2.0" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="Volcanic.AgenticCore.Extensions.Tests" />
</ItemGroup>
</Project>