- redact /mcp output, and stderr lines as they arrive (log, errors, tail)
- take host servers by name: configuration binding sorts keys anyway
- dispose kills the tree only without an exit code and only if the pid's
start time matches the spawn (no kill of a reused pid)
- idle timer generation guards stale callbacks; catalogue check runs in
the background with a 10 s cap; shutdown stops servers concurrently
- stop resets a failed status; /mcp shows an exited process; ordinal names
- spec: ${workspace} in command, ordering, dispose and /mcp wording
120 lines
6.0 KiB
C#
120 lines
6.0 KiB
C#
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. They are taken in name order (configuration binding sorts keys, so the order in appsettings.json
|
|
/// is lost); the first by name wins a tool name. Set a <c>toolPrefix</c> to keep both.
|
|
/// </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>The startup log line: <c>MCP: playwright enabled (headless, chromium)</c> or how to enable it.</summary>
|
|
public string DescribePlaywright() => PlaywrightPreset.Describe(Playwright);
|
|
}
|
|
|
|
/// <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 <see cref="PlaywrightPreset.DefaultPackage"/>.</summary>
|
|
public string? Package { get; set; }
|
|
/// <summary>Run this binary instead of <c>npx -y <package></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;
|
|
}
|